"""
Per-application audio capture via the Windows Process Loopback API.

`ProcessLoopbackRecorder` captures the render audio of ONE process tree
(ActivateAudioInterfaceAsync + AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK,
available since Windows 10 2004 / build 19041) directly as 16 kHz mono
float32 — device-independent, so it also works where device loopback fails
(hardware-offloaded headsets) and keeps unrelated audio (music, system
sounds) out of the meeting transcript.

`list_audio_apps()` enumerates the processes currently rendering audio so the
UI can offer "record just this app".

Written against the Microsoft SDK documentation (audioclient.h,
audioclientactivationparams.h, mmdeviceapi.h, audiopolicy.h). Uses only the
comtypes core (COMObject/COMMETHOD/GUID) — no comtypes.client codegen, which
keeps the Nuitka frozen build simple.
"""
from __future__ import annotations

import ctypes
import os
import sys
import threading
import time
from ctypes import POINTER, byref, c_ubyte, c_uint32, c_uint64, c_void_p, wintypes

import numpy as np
from loguru import logger

# comtypes initializes COM at IMPORT time and RAISES on RPC_E_CHANGED_MODE.
# Force MTA as its preference before it is ever imported — MTA succeeds on
# our capture threads and returns S_FALSE (harmless) where MTA already holds.
sys.coinit_flags = getattr(sys, "coinit_flags", 0)

SAMPLE_RATE = 16000
CHANNELS = 1

_VAD_PROCESS_LOOPBACK = "VAD\\Process_Loopback"
_ACTIVATION_TYPE_PROCESS_LOOPBACK = 1
_LOOPBACK_MODE_INCLUDE_TREE = 0
_AUDCLNT_STREAMFLAGS_LOOPBACK = 0x00020000
_AUDCLNT_SHAREMODE_SHARED = 0
_WAVE_FORMAT_IEEE_FLOAT = 0x0003
_VT_BLOB = 65
_REFERENCE_TIME = ctypes.c_longlong

# Session enumeration
_CLSCTX_ALL = 23
_ERENDER = 0
_DEVICE_STATE_ACTIVE = 1
_AUDIO_SESSION_STATE_ACTIVE = 1

# Friendly names for well-known meeting/browser executables.
_KNOWN_APPS = {
    "ms-teams": "Microsoft Teams",
    "teams": "Microsoft Teams",
    "zoom": "Zoom",
    "whatsapp": "WhatsApp",
    "chrome": "Google Chrome",
    "msedge": "Microsoft Edge",
    "firefox": "Firefox",
    "discord": "Discord",
    "slack": "Slack",
    "skype": "Skype",
    "webex": "Webex",
    "cisco webex meetings": "Webex",
    "telegram": "Telegram",
    "signal": "Signal",
}


def is_supported() -> bool:
    """Process loopback needs Windows 10 2004+ (build 19041)."""
    try:
        return sys.getwindowsversion().build >= 19041
    except Exception:
        return False


def friendly_app_name(exe_path: str) -> str:
    stem = os.path.splitext(os.path.basename(exe_path))[0]
    return _KNOWN_APPS.get(stem.lower(), stem)


# ─── comtypes definitions (lazy import so tests can run headless) ───────────

def _import_comtypes():
    """Import comtypes safely from any thread.

    First import runs comtypes' own CoInitializeEx(sys.coinit_flags=MTA).
    On a thread that is already STA (Qt's GUI thread) that raises
    RPC_E_CHANGED_MODE and Python discards the half-imported module — so we
    retry the import on a short-lived MTA helper thread; afterwards the module
    is cached and importing it from any thread is a no-op.
    """
    if "comtypes" in sys.modules:
        import comtypes
        return comtypes
    try:
        import comtypes
        return comtypes
    except OSError:
        err = {}

        def _worker():
            try:
                ctypes.windll.ole32.CoInitializeEx(None, 0)
                import comtypes  # noqa: F401
            except Exception as e:  # pragma: no cover
                err["e"] = e

        th = threading.Thread(target=_worker, name="comtypes-import", daemon=True)
        th.start()
        th.join(10)
        if err:
            raise err["e"]
        import comtypes
        return comtypes


def _com():
    comtypes = _import_comtypes()
    from comtypes import COMMETHOD, GUID, IUnknown

    ns = getattr(_com, "_cache", None)
    if ns is not None:
        return ns

    class PROCESS_LOOPBACK_PARAMS(ctypes.Structure):
        _fields_ = [("TargetProcessId", wintypes.DWORD),
                    ("ProcessLoopbackMode", wintypes.DWORD)]

    class ACTIVATION_PARAMS(ctypes.Structure):
        _fields_ = [("ActivationType", wintypes.DWORD),
                    ("ProcessLoopbackParams", PROCESS_LOOPBACK_PARAMS)]

    class WAVEFORMATEX(ctypes.Structure):
        _pack_ = 1
        _fields_ = [("wFormatTag", wintypes.WORD), ("nChannels", wintypes.WORD),
                    ("nSamplesPerSec", wintypes.DWORD),
                    ("nAvgBytesPerSec", wintypes.DWORD),
                    ("nBlockAlign", wintypes.WORD), ("wBitsPerSample", wintypes.WORD),
                    ("cbSize", wintypes.WORD)]

    class BLOB(ctypes.Structure):
        _fields_ = [("cbSize", c_uint32), ("pBlobData", c_void_p)]

    class PROPVARIANT(ctypes.Structure):
        # 8-byte header + 16-byte union on x64. Windows reads this by value;
        # do not pad beyond the documented layout.
        _fields_ = [("vt", ctypes.c_ushort), ("wReserved1", ctypes.c_ushort),
                    ("wReserved2", ctypes.c_ushort), ("wReserved3", ctypes.c_ushort),
                    ("blob", BLOB)]

    class IActivateAudioInterfaceAsyncOperation(IUnknown):
        _iid_ = GUID("{72A22D78-CDE4-431D-B8CC-843A71199B6D}")
        _methods_ = [COMMETHOD([], comtypes.HRESULT, "GetActivateResult",
                               (["out"], POINTER(comtypes.HRESULT), "r"),
                               (["out"], POINTER(POINTER(IUnknown)), "itf"))]

    class IActivateAudioInterfaceCompletionHandler(IUnknown):
        _iid_ = GUID("{41D949AB-9862-444A-80F6-C261334DA5EB}")
        _methods_ = [COMMETHOD([], comtypes.HRESULT, "ActivateCompleted",
                               (["in"], POINTER(IActivateAudioInterfaceAsyncOperation),
                                "op"))]

    class IAgileObject(IUnknown):
        # Marker interface: apartment-agile completion handler. Without it,
        # ActivateAudioInterfaceAsync rejects the callback (E_ILLEGAL_METHOD_CALL).
        _iid_ = GUID("{94EA2B94-E9CC-49E0-C0FF-EE64CA8F5B90}")
        _methods_ = []

    class IAudioClient(IUnknown):
        # Full vtable order per audioclient.h — do not reorder or omit.
        _iid_ = GUID("{1CB9AD4C-DBFA-4C32-B178-C2F568A703B2}")
        _methods_ = [
            COMMETHOD([], comtypes.HRESULT, "Initialize",
                      (["in"], c_uint32, "mode"), (["in"], c_uint32, "flags"),
                      (["in"], _REFERENCE_TIME, "dur"), (["in"], _REFERENCE_TIME, "per"),
                      (["in"], POINTER(WAVEFORMATEX), "fmt"),
                      (["in"], POINTER(GUID), "sess")),
            COMMETHOD([], comtypes.HRESULT, "GetBufferSize",
                      (["out"], POINTER(c_uint32), "n")),
            COMMETHOD([], comtypes.HRESULT, "GetStreamLatency",
                      (["out"], POINTER(_REFERENCE_TIME), "l")),
            COMMETHOD([], comtypes.HRESULT, "GetCurrentPadding",
                      (["out"], POINTER(c_uint32), "p")),
            COMMETHOD([], comtypes.HRESULT, "IsFormatSupported",
                      (["in"], c_uint32, "m"), (["in"], POINTER(WAVEFORMATEX), "f"),
                      (["out"], POINTER(POINTER(WAVEFORMATEX)), "c")),
            COMMETHOD([], comtypes.HRESULT, "GetMixFormat",
                      (["out"], POINTER(POINTER(WAVEFORMATEX)), "f")),
            COMMETHOD([], comtypes.HRESULT, "GetDevicePeriod",
                      (["out"], POINTER(_REFERENCE_TIME), "d"),
                      (["out"], POINTER(_REFERENCE_TIME), "m")),
            COMMETHOD([], comtypes.HRESULT, "Start"),
            COMMETHOD([], comtypes.HRESULT, "Stop"),
            COMMETHOD([], comtypes.HRESULT, "Reset"),
            COMMETHOD([], comtypes.HRESULT, "SetEventHandle",
                      (["in"], wintypes.HANDLE, "h")),
            COMMETHOD([], comtypes.HRESULT, "GetService",
                      (["in"], POINTER(GUID), "riid"),
                      (["out"], POINTER(c_void_p), "ppv")),
        ]

    class IAudioCaptureClient(IUnknown):
        _iid_ = GUID("{C8ADBD64-E71E-48A0-A4DE-185C395CD317}")
        _methods_ = [
            COMMETHOD([], comtypes.HRESULT, "GetBuffer",
                      (["out"], POINTER(POINTER(c_ubyte)), "data"),
                      (["out"], POINTER(c_uint32), "frames"),
                      (["out"], POINTER(c_uint32), "flags"),
                      (["out"], POINTER(c_uint64), "devpos"),
                      (["out"], POINTER(c_uint64), "qpcpos")),
            COMMETHOD([], comtypes.HRESULT, "ReleaseBuffer", (["in"], c_uint32, "n")),
            COMMETHOD([], comtypes.HRESULT, "GetNextPacketSize",
                      (["out"], POINTER(c_uint32), "n")),
        ]

    class CompletionHandler(comtypes.COMObject):
        _com_interfaces_ = [IActivateAudioInterfaceCompletionHandler, IAgileObject]

        def __init__(self, event):
            super().__init__()
            self._event = event

        def ActivateCompleted(self, op):
            ctypes.windll.kernel32.SetEvent(self._event)
            return 0

    # ── session enumeration interfaces (audiopolicy.h / mmdeviceapi.h) ──

    class IMMDevice(IUnknown):
        _iid_ = GUID("{D666063F-1587-4E43-81F1-B948E807363F}")
        _methods_ = [
            COMMETHOD([], comtypes.HRESULT, "Activate",
                      (["in"], POINTER(GUID), "iid"), (["in"], c_uint32, "clsctx"),
                      (["in"], c_void_p, "params"),
                      (["out"], POINTER(c_void_p), "itf")),
            COMMETHOD([], comtypes.HRESULT, "OpenPropertyStore",
                      (["in"], c_uint32, "access"), (["out"], POINTER(c_void_p), "ps")),
            COMMETHOD([], comtypes.HRESULT, "GetId",
                      (["out"], POINTER(ctypes.c_wchar_p), "id")),
            COMMETHOD([], comtypes.HRESULT, "GetState",
                      (["out"], POINTER(c_uint32), "state")),
        ]

    class IMMDeviceCollection(IUnknown):
        _iid_ = GUID("{0BD7A1BE-7A1A-44DB-8397-CC5392387B5E}")
        _methods_ = [
            COMMETHOD([], comtypes.HRESULT, "GetCount",
                      (["out"], POINTER(c_uint32), "n")),
            COMMETHOD([], comtypes.HRESULT, "Item",
                      (["in"], c_uint32, "i"),
                      (["out"], POINTER(POINTER(IMMDevice)), "dev")),
        ]

    class IMMDeviceEnumerator(IUnknown):
        _iid_ = GUID("{A95664D2-9614-4F35-A746-DE8DB63617E6}")
        _methods_ = [
            COMMETHOD([], comtypes.HRESULT, "EnumAudioEndpoints",
                      (["in"], c_uint32, "flow"), (["in"], c_uint32, "state"),
                      (["out"], POINTER(POINTER(IMMDeviceCollection)), "devs")),
            COMMETHOD([], comtypes.HRESULT, "GetDefaultAudioEndpoint",
                      (["in"], c_uint32, "flow"), (["in"], c_uint32, "role"),
                      (["out"], POINTER(POINTER(IMMDevice)), "dev")),
            COMMETHOD([], comtypes.HRESULT, "GetDevice",
                      (["in"], ctypes.c_wchar_p, "id"),
                      (["out"], POINTER(POINTER(IMMDevice)), "dev")),
            COMMETHOD([], comtypes.HRESULT, "RegisterEndpointNotificationCallback",
                      (["in"], c_void_p, "cb")),
            COMMETHOD([], comtypes.HRESULT, "UnregisterEndpointNotificationCallback",
                      (["in"], c_void_p, "cb")),
        ]

    class IAudioSessionControl(IUnknown):
        _iid_ = GUID("{F4B1A599-7266-4319-A8CA-E70ACB11E8CD}")
        _methods_ = [
            COMMETHOD([], comtypes.HRESULT, "GetState",
                      (["out"], POINTER(c_uint32), "state")),
            COMMETHOD([], comtypes.HRESULT, "GetDisplayName",
                      (["out"], POINTER(ctypes.c_wchar_p), "name")),
            COMMETHOD([], comtypes.HRESULT, "SetDisplayName",
                      (["in"], ctypes.c_wchar_p, "name"), (["in"], POINTER(GUID), "ctx")),
            COMMETHOD([], comtypes.HRESULT, "GetIconPath",
                      (["out"], POINTER(ctypes.c_wchar_p), "path")),
            COMMETHOD([], comtypes.HRESULT, "SetIconPath",
                      (["in"], ctypes.c_wchar_p, "path"), (["in"], POINTER(GUID), "ctx")),
            COMMETHOD([], comtypes.HRESULT, "GetGroupingParam",
                      (["out"], POINTER(GUID), "g")),
            COMMETHOD([], comtypes.HRESULT, "SetGroupingParam",
                      (["in"], POINTER(GUID), "g"), (["in"], POINTER(GUID), "ctx")),
            COMMETHOD([], comtypes.HRESULT, "RegisterAudioSessionNotification",
                      (["in"], c_void_p, "cb")),
            COMMETHOD([], comtypes.HRESULT, "UnregisterAudioSessionNotification",
                      (["in"], c_void_p, "cb")),
        ]

    class IAudioSessionControl2(IAudioSessionControl):
        _iid_ = GUID("{BFB7FF88-7239-4FC9-8FA2-07C950BE9C6D}")
        _methods_ = [
            COMMETHOD([], comtypes.HRESULT, "GetSessionIdentifier",
                      (["out"], POINTER(ctypes.c_wchar_p), "id")),
            COMMETHOD([], comtypes.HRESULT, "GetSessionInstanceIdentifier",
                      (["out"], POINTER(ctypes.c_wchar_p), "id")),
            COMMETHOD([], comtypes.HRESULT, "GetProcessId",
                      (["out"], POINTER(c_uint32), "pid")),
            COMMETHOD([], comtypes.HRESULT, "IsSystemSoundsSession"),
            COMMETHOD([], comtypes.HRESULT, "SetDuckingPreference",
                      (["in"], wintypes.BOOL, "optout")),
        ]

    class IAudioSessionEnumerator(IUnknown):
        _iid_ = GUID("{E2F5BB11-0570-40CA-ACDD-3AA01277DEE8}")
        _methods_ = [
            COMMETHOD([], comtypes.HRESULT, "GetCount",
                      (["out"], POINTER(ctypes.c_int), "n")),
            COMMETHOD([], comtypes.HRESULT, "GetSession",
                      (["in"], ctypes.c_int, "i"),
                      (["out"], POINTER(POINTER(IAudioSessionControl)), "s")),
        ]

    class IAudioSessionManager2(IUnknown):
        # IAudioSessionManager methods first (vtable order!), then Manager2.
        _iid_ = GUID("{77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F}")
        _methods_ = [
            COMMETHOD([], comtypes.HRESULT, "GetAudioSessionControl",
                      (["in"], POINTER(GUID), "sess"), (["in"], c_uint32, "flags"),
                      (["out"], POINTER(c_void_p), "ctl")),
            COMMETHOD([], comtypes.HRESULT, "GetSimpleAudioVolume",
                      (["in"], POINTER(GUID), "sess"), (["in"], c_uint32, "flags"),
                      (["out"], POINTER(c_void_p), "vol")),
            COMMETHOD([], comtypes.HRESULT, "GetSessionEnumerator",
                      (["out"], POINTER(POINTER(IAudioSessionEnumerator)), "e")),
            COMMETHOD([], comtypes.HRESULT, "RegisterSessionNotification",
                      (["in"], c_void_p, "cb")),
            COMMETHOD([], comtypes.HRESULT, "UnregisterSessionNotification",
                      (["in"], c_void_p, "cb")),
            COMMETHOD([], comtypes.HRESULT, "RegisterDuckNotification",
                      (["in"], ctypes.c_wchar_p, "sid"), (["in"], c_void_p, "cb")),
            COMMETHOD([], comtypes.HRESULT, "UnregisterDuckNotification",
                      (["in"], c_void_p, "cb")),
        ]

    CLSID_MMDeviceEnumerator = GUID("{BCDE0395-E52F-467C-8E3D-C4579291692E}")

    ns = {
        "comtypes": comtypes, "GUID": GUID,
        "PROCESS_LOOPBACK_PARAMS": PROCESS_LOOPBACK_PARAMS,
        "ACTIVATION_PARAMS": ACTIVATION_PARAMS,
        "WAVEFORMATEX": WAVEFORMATEX, "PROPVARIANT": PROPVARIANT,
        "IActivateAudioInterfaceAsyncOperation": IActivateAudioInterfaceAsyncOperation,
        "IActivateAudioInterfaceCompletionHandler": IActivateAudioInterfaceCompletionHandler,
        "IAudioClient": IAudioClient, "IAudioCaptureClient": IAudioCaptureClient,
        "CompletionHandler": CompletionHandler,
        "IMMDeviceEnumerator": IMMDeviceEnumerator, "IMMDevice": IMMDevice,
        "IMMDeviceCollection": IMMDeviceCollection,
        "IAudioSessionManager2": IAudioSessionManager2,
        "IAudioSessionEnumerator": IAudioSessionEnumerator,
        "IAudioSessionControl": IAudioSessionControl,
        "IAudioSessionControl2": IAudioSessionControl2,
        "CLSID_MMDeviceEnumerator": CLSID_MMDeviceEnumerator,
    }
    _com._cache = ns
    return ns


def _co_init_mta() -> None:
    """Join the MTA (tolerates S_FALSE / RPC_E_CHANGED_MODE)."""
    try:
        ctypes.windll.ole32.CoInitializeEx(None, 0)
    except Exception:
        pass


# ─── Recorder ────────────────────────────────────────────────────────────────

class ProcessLoopbackRecorder:
    """Captures one process tree's audio as 16 kHz mono float32.

    Context-manager with a blocking `record(numframes)` — signature-compatible
    with soundcard's recorder so the meeting capture loop can use either.
    If the target stops rendering (app closed/muted), record() returns
    silence after a short wait so the caller's stop-check keeps running.
    """

    _IDLE_TIMEOUT_S = 1.0

    def __init__(self, pid: int) -> None:
        self.pid = int(pid)
        self._client = None
        self._capture = None
        self._pending = np.zeros(0, dtype=np.float32)

    def __enter__(self) -> "ProcessLoopbackRecorder":
        ns = _com()
        _co_init_mta()

        params = ns["ACTIVATION_PARAMS"]()
        params.ActivationType = _ACTIVATION_TYPE_PROCESS_LOOPBACK
        params.ProcessLoopbackParams.TargetProcessId = self.pid
        params.ProcessLoopbackParams.ProcessLoopbackMode = _LOOPBACK_MODE_INCLUDE_TREE

        pv = ns["PROPVARIANT"]()
        pv.vt = _VT_BLOB
        pv.blob.cbSize = ctypes.sizeof(params)
        pv.blob.pBlobData = ctypes.cast(byref(params), c_void_p)

        event = ctypes.windll.kernel32.CreateEventW(None, True, False, None)
        try:
            handler = ns["CompletionHandler"](event)
            op = POINTER(ns["IActivateAudioInterfaceAsyncOperation"])()
            fn = ctypes.windll.mmdevapi.ActivateAudioInterfaceAsync
            fn.restype = ctypes.c_long
            hr = fn(
                ctypes.c_wchar_p(_VAD_PROCESS_LOOPBACK),
                byref(ns["IAudioClient"]._iid_),
                byref(pv),
                handler.QueryInterface(
                    ns["IActivateAudioInterfaceCompletionHandler"]),
                byref(op),
            )
            if hr != 0:
                raise OSError(f"ActivateAudioInterfaceAsync 0x{hr & 0xFFFFFFFF:08X}")
            if ctypes.windll.kernel32.WaitForSingleObject(event, 5000) != 0:
                raise OSError("process-loopback activation timed out")
            hr_act, itf = op.GetActivateResult()
            if hr_act != 0:
                raise OSError(f"GetActivateResult 0x{hr_act & 0xFFFFFFFF:08X}")
        finally:
            ctypes.windll.kernel32.CloseHandle(event)

        client = itf.QueryInterface(ns["IAudioClient"])
        fmt = ns["WAVEFORMATEX"]()
        fmt.wFormatTag = _WAVE_FORMAT_IEEE_FLOAT
        fmt.nChannels = CHANNELS
        fmt.nSamplesPerSec = SAMPLE_RATE
        fmt.wBitsPerSample = 32
        fmt.nBlockAlign = CHANNELS * 4
        fmt.nAvgBytesPerSec = SAMPLE_RATE * fmt.nBlockAlign
        fmt.cbSize = 0
        client.Initialize(_AUDCLNT_SHAREMODE_SHARED, _AUDCLNT_STREAMFLAGS_LOOPBACK,
                          2_000_000, 0, byref(fmt), None)  # 200 ms buffer
        ppv = client.GetService(byref(ns["IAudioCaptureClient"]._iid_))
        self._capture = ctypes.cast(ppv, POINTER(ns["IAudioCaptureClient"]))
        client.Start()
        self._client = client
        logger.info("Process loopback capture started (pid={})", self.pid)
        return self

    def __exit__(self, *exc) -> bool:
        try:
            if self._client is not None:
                self._client.Stop()
        except Exception:
            logger.exception("process loopback stop failed")
        self._client = None
        self._capture = None
        return False

    def record(self, numframes: int) -> np.ndarray:
        """Block until `numframes` mono samples are available (or the target
        goes idle — then pad with silence so the caller keeps cycling)."""
        out = [self._pending]
        have = len(self._pending)
        idle_since = None
        while have < numframes:
            n = self._capture.GetNextPacketSize()
            if n == 0:
                now = time.monotonic()
                if idle_since is None:
                    idle_since = now
                elif now - idle_since >= self._IDLE_TIMEOUT_S:
                    pad = np.zeros(numframes - have, dtype=np.float32)
                    out.append(pad)
                    have = numframes
                    break
                time.sleep(0.01)
                continue
            idle_since = None
            data, frames, flags, dev, qpc = self._capture.GetBuffer()
            if frames:
                buf = ctypes.string_at(data, frames * CHANNELS * 4)
                out.append(np.frombuffer(buf, dtype=np.float32).copy())
                have += frames
            self._capture.ReleaseBuffer(frames)
        merged = np.concatenate(out) if len(out) > 1 else out[0]
        self._pending = merged[numframes:]
        return merged[:numframes].reshape(-1, 1)


# ─── "Which apps are playing audio right now?" ──────────────────────────────

def _exe_path_for_pid(pid: int) -> str:
    PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
    h = ctypes.windll.kernel32.OpenProcess(
        PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
    if not h:
        return ""
    try:
        size = wintypes.DWORD(1024)
        buf = ctypes.create_unicode_buffer(size.value)
        if ctypes.windll.kernel32.QueryFullProcessImageNameW(h, 0, buf, byref(size)):
            return buf.value
        return ""
    finally:
        ctypes.windll.kernel32.CloseHandle(h)


def list_audio_apps() -> list[dict]:
    """Processes with an ACTIVE render audio session on any active output
    device: [{"pid": int, "name": str}], deduped, own process excluded."""
    if not is_supported():
        return []
    ns = _com()
    _co_init_mta()
    comtypes = ns["comtypes"]

    apps: dict[int, str] = {}
    own_pid = os.getpid()
    try:
        enum = comtypes.CoCreateInstance(
            ns["CLSID_MMDeviceEnumerator"],
            interface=ns["IMMDeviceEnumerator"],
            clsctx=_CLSCTX_ALL,
        )
        devices = enum.EnumAudioEndpoints(_ERENDER, _DEVICE_STATE_ACTIVE)
        for i in range(devices.GetCount()):
            dev = devices.Item(i)
            try:
                mgr_ptr = dev.Activate(
                    byref(ns["IAudioSessionManager2"]._iid_), _CLSCTX_ALL, None)
                mgr = ctypes.cast(mgr_ptr, POINTER(ns["IAudioSessionManager2"]))
                sessions = mgr.GetSessionEnumerator()
                for j in range(sessions.GetCount()):
                    ctl = sessions.GetSession(j)
                    try:
                        ctl2 = ctl.QueryInterface(ns["IAudioSessionControl2"])
                        if ctl.GetState() != _AUDIO_SESSION_STATE_ACTIVE:
                            continue
                        pid = int(ctl2.GetProcessId())
                        if pid in (0, own_pid) or pid in apps:
                            continue
                        exe = _exe_path_for_pid(pid)
                        if not exe:
                            continue
                        apps[pid] = friendly_app_name(exe)
                    except Exception:
                        continue
            except Exception:
                logger.debug("session scan failed for one device", exc_info=True)
    except Exception:
        logger.exception("audio app enumeration failed")
        return []
    return [{"pid": pid, "name": name} for pid, name in
            sorted(apps.items(), key=lambda kv: kv[1].lower())]
