All Articles

PowerShell から COM インターフェースを使用してオーディオデバイスの設定を切り替える

僕の PC では基本的に接続しているスピーカーで適当なプレイリストを流し続けているのですが、オンライン英会話をやるためだけに毎回設定アプリからヘッドセットにサウンドを切り替えるのがかなり手間でした。

そこで、ヘッドセットの切り替えがめんどくさいことを理由に英会話をサボることができないように簡単にショートカットでサウンドデバイスを切り替えできるようにすることにしました。

しかし、Windows の場合サウンドデバイスの切り替え機能は標準では用意されていないらしく、何らかのサードパーティツールを使うか独自に切り替えツールを実装する必要があるようでした。

プライベートで使用しているメイン機には基本的によくわからないツールを入れたくない主義なので、今回は PowerShell で切り替えツールを実装することにしました。

スクリプトの作成にあたっては、以下の記事を多分に参考にさせてもらいました(ほぼ丸パクリしました)。

参考:Windowsの音声出力先を変えるショートカット作成 - itiblog

やってみると COM インターフェースを使用する必要があるなど意外と学びが多かったのでこちらの記事でまとめていきます。

もくじ

作成したスクリプト

最終的に作成したスクリプトは以下です。

このスクリプトのオーディオデバイスの ID を任意に変更した上で ps1 ファイルとして保存して実行することでデバイスの切り替えができます。

$csharpCode = @"
using System;
using System.Runtime.InteropServices;

public enum EDataFlow
{
    eRender,
    eCapture,
    eAll,
}

public enum ERole
{
    eConsole,
    eMultimedia,
    eCommunications,
}

[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("D666063F-1587-4E43-81F1-B948E807363F")]
public interface IMMDevice
{
    int Activate();
    int OpenPropertyStore();
    int GetId([MarshalAs(UnmanagedType.LPWStr)] out string ppstrId);
    int GetState();
}

[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")]
public interface IMMDeviceEnumerator
{
    int EnumAudioEndpoints();
    int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice ppEndpoint);
}

[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
public class MMDeviceEnumerator { }

public class AudioHelper
{
    public static string GetDefaultDeviceId()
    {
        IMMDeviceEnumerator enumerator = new MMDeviceEnumerator() as IMMDeviceEnumerator;
        IMMDevice device;
        enumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eConsole, out device);
        string id;
        device.GetId(out id);
        return id;
    }
}

[Guid("F8679F50-850A-41CF-9C72-430F290290C8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IPolicyConfig
{
    [PreserveSig]
    int GetMixFormat();
    [PreserveSig]
    int GetDeviceFormat();
    [PreserveSig]
    int ResetDeviceFormat();
    [PreserveSig]
    int SetDeviceFormat();
    [PreserveSig]
    int GetProcessingPeriod();
    [PreserveSig]
    int SetProcessingPeriod();
    [PreserveSig]
    int GetShareMode();
    [PreserveSig]
    int SetShareMode();
    [PreserveSig]
    int GetPropertyValue();
    [PreserveSig]
    int SetPropertyValue();
    [PreserveSig]
    int SetDefaultEndpoint(
        [In] [MarshalAs(UnmanagedType.LPWStr)] string deviceId, 
        [In] [MarshalAs(UnmanagedType.U4)] ERole role);
    [PreserveSig]
    int SetEndpointVisibility();
}

[ComImport, Guid("870AF99C-171D-4F9E-AF0D-E63DF40C2BC9")]
internal class _CPolicyConfigClient
{
}
public class PolicyConfigClient
{
    public static int SetDefaultDevice(string deviceID)
    {
        IPolicyConfig _policyConfigClient = (new _CPolicyConfigClient() as IPolicyConfig);
        try {
            Marshal.ThrowExceptionForHR(_policyConfigClient.SetDefaultEndpoint(deviceID, ERole.eConsole));
            Marshal.ThrowExceptionForHR(_policyConfigClient.SetDefaultEndpoint(deviceID, ERole.eMultimedia));
            Marshal.ThrowExceptionForHR(_policyConfigClient.SetDefaultEndpoint(deviceID, ERole.eCommunications));
            return 0;
        } catch {
            return 1;
        }
    }
}
"@

Add-Type -TypeDefinition $csharpCode

$currentDeviceId = [AudioHelper]::GetDefaultDeviceId()
$deviceId = ""
$deviceA = "<デバイス A の ID>"
$deviceB = "<デバイス B の ID>"

if($currentDeviceId -eq "{0.0.0.00000000}.$deviceA") {
	$deviceId = $deviceB
}
else {
	$deviceId = $deviceA
}

# Change Audio device
[PolicyConfigClient]::SetDefaultDevice("{0.0.0.00000000}.$deviceId")

Windows のオーディオデバイスの ID 取得方法

まず、参考にした こちらの記事 の情報から以下のスクリプトなどを使用してレジストリ内の情報を確認することでデバイスに存在するオーディオデバイスの ID を含む情報を取得できることを確認しました。

$regRoot = "HKLM:\Software\Microsoft\"

function Get-Devices
{
    $regKey = $regRoot + "\Windows\CurrentVersion\MMDevices\Audio\Render\"
    Write-Output "Active Sound devices:"
    Get-ChildItem $regKey | Where-Object { $_.GetValue("DeviceState") -eq 1} |
        Foreach-Object {
            $subKey = $_.OpenSubKey("Properties")
            Write-Output ("  " + $subKey.GetValue("{a45c254e-df1c-4efd-8020-67d146a850e0},2"))
            Write-Output ("    " + $_.Name.Substring($_.Name.LastIndexOf("\")))
        }
}

Get-Devices

しかし、今回は使用中のオーディオデバイスの ID を特定し、自動的に現在使用していない別のオーディオデバイスに変更する Toggle スイッチを実装したいので、Core Audio API を使用して現在使用中のオーディオデバイスの ID を取得する実装もスクリプト内に組み込むことにしました。

IMMDeviceEnumerator インターフェースを使用して使用中のオーディオデバイスを確認する

デバイスで使用しているオーディオデバイスの ID を特定するため、今回は IMMDeviceEnumerator インターフェースの GetDefaultAudioEndpoint メソッドを使用しました。

IMMDeviceEnumerator インターフェースはオーディオデバイスの変更時に使用する IPolicyConfig インターフェースとは異なり正式にドキュメント化されているインターフェースのようです。

参考:IMMDeviceEnumerator (mmdeviceapi.h) - Win32 apps | Microsoft Learn

参考:IMMDeviceEnumerator::GetDefaultAudioEndpoint (mmdeviceapi.h) - Win32 apps | Microsoft Learn

これを使用し、Core Audio API を使用して現在使用中のオーディオデバイスの ID を取得するために以下のコードを作成しました。

public enum EDataFlow
{
    eRender,
    eCapture,
    eAll,
}

public enum ERole
{
    eConsole,
    eMultimedia,
    eCommunications,
}


[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("D666063F-1587-4E43-81F1-B948E807363F")]
public interface IMMDevice
{
    int Activate();
    int OpenPropertyStore();
    int GetId([MarshalAs(UnmanagedType.LPWStr)] out string ppstrId);
    int GetState();
}


[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")]
public interface IMMDeviceEnumerator
{
    int EnumAudioEndpoints();
    int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice ppEndpoint);
}


[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
public class MMDeviceEnumerator { }

public class AudioHelper
{
    public static string GetDefaultDeviceId()
    {
        IMMDeviceEnumerator enumerator = new MMDeviceEnumerator() as IMMDeviceEnumerator;
        IMMDevice device;
        enumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eConsole, out device);
        string id;
        device.GetId(out id);
        return id;
    }
}

上記を定義した上で [AudioHelper]::GetDefaultDeviceId() を実行することで現在端末に適用されているオーディオデバイスの ID を取得できます。

Windows のオーディオデバイス変更方法

参考にした こちらの記事 の情報から、以下のステップで Windows の音声出力先を変更可能であることを確認しました。

  1. IPolicyConfig インタフェースを実装した PolicyConfigClient を作成する
  2. PolicyConfigClient に IPolicyConfig に含まれる SetDefaultDevice を実装する
  3. SetDefaultDevice にデバイスの UUID とシステムがオーディオエンドポイントデバイスに割り当てた役割を示す ERole を引数として実行することでオーディオの再生デバイスを変更する

IPolicyConfig インターフェースとは

IPolicyConfig は Windows のオーディオデバイスのポリシー設定を管理するためのメソッドを提供する非公開の COM インターフェースであり、COM CLSID は F8679F50-850A-41CF-9C72-430F290290C8 です。

このインターフェースについて、以下の Issue では、MS の開発者から undocumented API である旨のコメントがあります。

参考:IPolicyConfig and PolicyConfigClient not created · Issue #1105 · microsoft/CsWin32

しかし、IPolicyConfig インターフェースを使用して Windows のオーディオデバイス設定を変更する際の使用方法については以下のリポジトリの SetDefaultAudioDevice.cpp などで実装を確認することができます。

参考:SetDefaultAudioDevice/SetDefaultAudioDevice.cpp

参考:AudioDeviceCmdlets/SOURCE/IPolicyConfig.cs

以下は、IPolicyConfig.cs で実装されている IPolicyConfig インターフェースの定義です。

C# から COM インターフェースを使用する実装となっています。

メソッド名の前の [PreserveSig] 属性は、COM から返却されたエラーコードを .Net 側で例外判定させないために追加が必要らしいです。

また、引数に使用している IntPtr は、COM 側で使用するネイティブ構造体などの情報をポインタとして渡すために使用しているようです。

参考:C#でCOMを使ってみた。 - echo(“備忘録”);

using System;
using System.Collections.Generic;
using System.Text;
using System.Runtime.InteropServices;

namespace CoreAudioApi.Interfaces
{
    [Guid("f8679f50-850a-41cf-9c72-430f290290c8"),
    InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
    internal interface IPolicyConfig
    {
        [PreserveSig]
        int GetMixFormat(string pszDeviceName, IntPtr ppFormat);

        [PreserveSig]
        int GetDeviceFormat(string pszDeviceName, bool bDefault, IntPtr ppFormat);

        [PreserveSig]
        int ResetDeviceFormat(string pszDeviceName);

        [PreserveSig]
        int SetDeviceFormat(string pszDeviceName, IntPtr pEndpointFormat, IntPtr MixFormat);

        [PreserveSig]
        int GetProcessingPeriod(string pszDeviceName, bool bDefault, IntPtr pmftDefaultPeriod, IntPtr pmftMinimumPeriod);

        [PreserveSig]
        int SetProcessingPeriod(string pszDeviceName, IntPtr pmftPeriod);

        [PreserveSig]
        int GetShareMode(string pszDeviceName, IntPtr pMode);

        [PreserveSig]
        int SetShareMode(string pszDeviceName, IntPtr mode);

        [PreserveSig]
        int GetPropertyValue(string pszDeviceName, bool bFxStore, IntPtr key, IntPtr pv);

        [PreserveSig]
        int SetPropertyValue(string pszDeviceName, bool bFxStore, IntPtr key, IntPtr pv);

        [PreserveSig]
        int SetDefaultEndpoint(string pszDeviceName, ERole role);

        [PreserveSig]
        int SetEndpointVisibility(string pszDeviceName, bool bVisible);
    }
}

このインターフェースにはいくつかのメソッドが含まれていますが、今回主に使用する必要があるのは特定のデバイスをオーディオ再生デバイスに設定することが可能な SetDefaultEndpoint メソッドです。

参考:windows - How can I programmatically set the default input and output audio device for an application? - Stack Overflow

前述の通り、SetDefaultDevice にデバイスの UUID とシステムがオーディオエンドポイントデバイスに割り当てた役割を示す ERole(eConsole、eMultimedia、eCommunications などのオーディオの役割を指定する列挙型) を引数として実行することでオーディオの再生デバイスを変更することができます。

この ERole は以下の通り定義を行っています。

public enum ERole
{
    eConsole,
    eMultimedia,
    eCommunications,
}

IPolicyConfig インターフェースを使用する PolicyConfigClient の実装

続いて、IPolicyConfig インターフェースを使用してオーディオデバイスを変更するメソッドを呼び出す PolicyConfigClient を実装します。

こちらも、先ほどと同じリポジトリ内の PolicyConfigClient.cs が参考になります。

参考:AudioDeviceCmdlets/SOURCE/PolicyConfigClient.cs

using System;
using System.Collections.Generic;
using System.Text;
using CoreAudioApi.Interfaces;
using System.Runtime.InteropServices;

namespace CoreAudioApi
{
    [ComImport, Guid("870af99c-171d-4f9e-af0d-e63df40c2bc9")]
    internal class _PolicyConfigClient
    {
    }

    public class PolicyConfigClient
    {
        private readonly IPolicyConfig _PolicyConfig;
        private readonly IPolicyConfigVista _PolicyConfigVista;
        private readonly IPolicyConfig10 _PolicyConfig10;

        public PolicyConfigClient()
        {
            _PolicyConfig = new _PolicyConfigClient() as IPolicyConfig;
            if (_PolicyConfig != null)
                return;

            _PolicyConfigVista = new _PolicyConfigClient() as IPolicyConfigVista;
            if (_PolicyConfigVista != null)
                return;

            _PolicyConfig10 = new _PolicyConfigClient() as IPolicyConfig10;
        }

        public void SetDefaultEndpoint(string devID, ERole eRole)
        {
            if (_PolicyConfig != null)
            {
                Marshal.ThrowExceptionForHR(_PolicyConfig.SetDefaultEndpoint(devID, eRole));
                return;
            }
            if (_PolicyConfigVista != null)
            {
                Marshal.ThrowExceptionForHR(_PolicyConfigVista.SetDefaultEndpoint(devID, eRole));
                return;
            }
            if (_PolicyConfig10 != null)
            {
                Marshal.ThrowExceptionForHR(_PolicyConfig10.SetDefaultEndpoint(devID, eRole));
            }
        }
    }
}

このコードでは、COM CLSID 870af99c-171d-4f9e-af0d-e63df40c2bc9 を使用して PolicyConfigClient のインターフェースを取得しています。

続いて、IPolicyConfig インターフェースの SetDefaultEndpoint メソッドを呼び出し、既定のオーディオデバイスを変更する操作を行っています。

作成した C Sharp コードを PowerShell から利用する

最後に、ここまでに作成した C# のコードを PowerShell から呼び出し、オーディオデバイスの設定をデバイス A と B のどちらかに切り替える Toggle スクリプトを作成します。

$csharpCode = @"
< C# Code >
"@

Add-Type -TypeDefinition $csharpCode

$currentDeviceId = [AudioHelper]::GetDefaultDeviceId()
$deviceId = ""
$deviceA = "<Device A の ID>"
$deviceB = "<Device B の ID>"

if($currentDeviceId -eq "{0.0.0.00000000}.$deviceA") {
	$deviceId = $deviceB
}
else {
	$deviceId = $deviceA
}

# Change Audio device
[PolicyConfigClient]::SetDefaultDevice("{0.0.0.00000000}.$deviceId")

作成した C# のコードは、以下のドキュメントのサンプルのように Add-Type -TypeDefinition $csharpCode で PowerShell から実行できるようにしています。

参考:Add-Type (Microsoft.PowerShell.Utility) - PowerShell | Microsoft Learn

Add-Type は他にもアセンブリオブジェクトやネイティブ Windows API などを PowerShell から呼び出す際にも使用できます。

まとめ

オーディオデバイスの変更を簡単にやろうと思っただけなのに非公式の COM インターフェースを使用する必要があるなど意外と面倒でしたが、学びがあったのでよかったです。