""" audio_panner.py --------------- Control the stereo pan position of individual Windows audio sessions (e.g. Edge, Teams, Spotify) using the Windows Core Audio API via pycaw. Requirements: pip install pycaw comtypes psutil Usage examples: # List all active audio sessions python audio_panner.py --list # Pan Microsoft Edge 80% to the left (-0.8) python audio_panner.py --app msedge --pan -0.8 # Pan Teams fully to the right (+1.0) python audio_panner.py --app teams --pan 1.0 # Apply a saved config file python audio_panner.py --config pans.json # Reset all sessions to centre python audio_panner.py --reset Pan value range: -1.0 (full left) → 0.0 (centre) → +1.0 (full right) """ import argparse import json import sys from pathlib import Path # --------------------------------------------------------------------------- # Dependency check # --------------------------------------------------------------------------- try: import psutil from pycaw.pycaw import AudioUtilities from comtypes import CLSCTX_ALL except ImportError as exc: sys.exit( f"Missing dependency: {exc}\n" "Install with: pip install pycaw comtypes psutil" ) # IChannelAudioVolume moved to pycaw.api.audioclient in newer pycaw builds; # fall back to the legacy pycaw.pycaw location for older installs. try: from pycaw.api.audioclient import IChannelAudioVolume except ImportError: try: from pycaw.pycaw import IChannelAudioVolume # type: ignore[no-redef] except ImportError: from comtypes import GUID from ctypes import HRESULT import comtypes # Last-resort: define the interface inline from the COM spec class IChannelAudioVolume(comtypes.IUnknown): _iid_ = GUID("{1C158861-B533-4B30-B1CF-E853E51C59B8}") _methods_ = [ comtypes.STDMETHOD(HRESULT, "GetChannelCount", [comtypes.POINTER(comtypes.c_uint)]), comtypes.STDMETHOD(HRESULT, "SetChannelVolume", [comtypes.c_uint, comtypes.c_float, comtypes.POINTER(GUID)]), comtypes.STDMETHOD(HRESULT, "GetChannelVolume", [comtypes.c_uint, comtypes.POINTER(comtypes.c_float)]), comtypes.STDMETHOD(HRESULT, "SetAllVolumes", [comtypes.c_uint, comtypes.POINTER(comtypes.c_float), comtypes.POINTER(GUID)]), comtypes.STDMETHOD(HRESULT, "GetAllVolumes", [comtypes.c_uint, comtypes.POINTER(comtypes.c_float)]), ] # --------------------------------------------------------------------------- # Core helpers # --------------------------------------------------------------------------- def get_sessions() -> list[dict]: """Return a list of active audio sessions with metadata.""" sessions = [] for session in AudioUtilities.GetAllSessions(): if session.Process is None: continue try: proc = psutil.Process(session.Process.pid) exe = proc.name() except (psutil.NoSuchProcess, psutil.AccessDenied): exe = session.Process.name() if session.Process else "unknown" sessions.append({ "pid": session.Process.pid, "exe": exe, "name": Path(exe).stem.lower(), # e.g. "msedge", "teams" "session": session, }) return sessions def pan_to_volumes(pan: float) -> tuple[float, float]: """ Convert a pan value [-1, +1] to (left_vol, right_vol) using a constant-power (equal-power) panning law. pan = -1.0 → left=1.0, right=0.0 pan = 0.0 → left=1.0, right=1.0 (both full, centred) pan = +1.0 → left=0.0, right=1.0 """ import math pan = max(-1.0, min(1.0, pan)) # clamp angle = (pan + 1.0) / 2.0 * (math.pi / 2) # 0 … π/2 left = math.cos(angle) right = math.sin(angle) return left, right def set_pan(session_obj, pan: float) -> bool: """ Apply stereo pan to a single audio session. Returns True on success, False if the session has no channel volume interface. """ try: volume = session_obj._ctl.QueryInterface(IChannelAudioVolume) count = volume.GetChannelCount() if count < 2: return False # mono stream – skip left, right = pan_to_volumes(pan) volume.SetChannelVolume(0, left, None) # channel 0 = left volume.SetChannelVolume(1, right, None) # channel 1 = right return True except Exception: return False def find_sessions_by_name(sessions: list[dict], name: str) -> list[dict]: """Case-insensitive substring match on the executable stem.""" needle = name.lower() return [s for s in sessions if needle in s["name"]] # --------------------------------------------------------------------------- # CLI actions # --------------------------------------------------------------------------- def cmd_list(sessions: list[dict]) -> None: if not sessions: print("No active audio sessions found.") return print(f"\n{'PID':>7} {'Executable':<30} {'Match key (use with --app)'}") print("-" * 65) seen = set() for s in sessions: tag = f"[dup pid={s['pid']}]" if s['name'] in seen else "" print(f"{s['pid']:>7} {s['exe']:<30} {s['name']} {tag}") seen.add(s['name']) print() def cmd_pan(sessions: list[dict], app: str, pan: float) -> None: matches = find_sessions_by_name(sessions, app) if not matches: print(f"No audio session found matching '{app}'.") print("Run --list to see active sessions.") return for s in matches: ok = set_pan(s["session"], pan) status = f"pan={pan:+.2f}" if ok else "skipped (mono or inaccessible)" print(f" {s['exe']} (pid {s['pid']}): {status}") def cmd_config(sessions: list[dict], config_path: str) -> None: path = Path(config_path) if not path.exists(): sys.exit(f"Config file not found: {config_path}") with path.open() as f: config: dict = json.load(f) print(f"Applying config from {config_path} …") for app, pan in config.items(): print(f" {app}:") cmd_pan(sessions, app, float(pan)) def cmd_reset(sessions: list[dict]) -> None: print("Resetting all sessions to centre (pan=0.0) …") for s in sessions: ok = set_pan(s["session"], 0.0) if ok: print(f" {s['exe']} (pid {s['pid']}): reset") # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( description="Per-app stereo panner for Windows audio sessions.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) group = p.add_mutually_exclusive_group(required=True) group.add_argument( "--list", "-l", action="store_true", help="List all active audio sessions.", ) group.add_argument( "--app", "-a", metavar="NAME", help="Executable name (or substring) to pan, e.g. msedge, teams, chrome.", ) group.add_argument( "--config", "-c", metavar="FILE", help='Path to a JSON config file, e.g. {"msedge": -0.8, "teams": 0.5}', ) group.add_argument( "--reset", "-r", action="store_true", help="Reset ALL sessions to stereo centre.", ) p.add_argument( "--pan", "-p", type=float, default=0.0, metavar="[-1.0 … +1.0]", help="Pan position: -1.0=full left, 0.0=centre, +1.0=full right. (used with --app)", ) return p def main() -> None: parser = build_parser() args = parser.parse_args() sessions = get_sessions() if args.list: cmd_list(sessions) elif args.app: if not (-1.0 <= args.pan <= 1.0): sys.exit("--pan must be between -1.0 and +1.0") cmd_pan(sessions, args.app, args.pan) elif args.config: cmd_config(sessions, args.config) elif args.reset: cmd_reset(sessions) if __name__ == "__main__": main()