프로그램에서 알림음을 낼 때 볼륨이 0이면 아무 소용이 없고, 반대로 키오스크나 매장용 프로그램은 시작할 때 볼륨을 일정 수준으로 맞춰 두고 싶을 때가 있습니다. 그런데 막상 찾아보면 .NET 기본 클래스 라이브러리에는 시스템 마스터 볼륨을 읽거나 바꾸는 클래스가 없습니다. NAudio 같은 라이브러리를 쓰면 되긴 하지만, 볼륨 하나 때문에 DLL 을 추가하기는 부담스럽죠. 그래서 Windows Vista 부터 들어 있는 Core Audio API(멀티미디어 장치 API)의 COM 인터페이스를 직접 선언해서 쓰는 방법을 정리해 둡니다. 다른 곳에서 본 코드를 바탕으로 VB.NET 프로젝트에 바로 넣어 쓸 수 있게 다듬은 모듈입니다.

Windows 볼륨 조절 화면

광고

쓰는 법

모듈을 프로젝트에 추가하면 함수 두 개만 쓰면 됩니다. 볼륨은 0~100 정수입니다.

Dim currentVolume As Integer = GetVolume()
SetVolume(30)

GetVolume() 은 실패하면 -1 을 돌려주므로, 반환값이 음수인지 먼저 확인하고 쓰는 게 좋습니다.

전체 코드

Option Strict Off

Imports System
Imports System.Runtime.InteropServices

Module WindowsSystemAudio

    <ComImport>
    <Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), _
     InterfaceType(ComInterfaceType.InterfaceIsIUnknown)>
    Private Interface IMMDeviceEnumerator
#Disable Warning IDE1006 ' Naming Styles
        Sub lpVtbl()
#Enable Warning IDE1006 ' Naming Styles
        Function GetDefaultAudioEndpoint(ByVal dataFlow As Integer, _
        ByVal role As Integer, <Out> ByRef ppDevice As IMMDevice) As Integer
    End Interface

    Private NotInheritable Class MMDeviceEnumeratorFactory
        Public Shared Function CreateInstance() As IMMDeviceEnumerator
            Return CType(Activator.CreateInstance(Type.GetTypeFromCLSID(New Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))), _
            IMMDeviceEnumerator) ' a MMDeviceEnumerator
        End Function
    End Class

    <Guid("D666063F-1587-4E43-81F1-B948E807363F"), _
     InterfaceType(ComInterfaceType.InterfaceIsIUnknown)>
    Private Interface IMMDevice
        Function Activate(
    <MarshalAs(UnmanagedType.LPStruct)> ByVal iid As Guid, _
     ByVal dwClsCtx As Integer, ByVal pActivationParams As IntPtr, <Out>
    <MarshalAs(UnmanagedType.IUnknown)> ByRef ppInterface As Object) As Integer
    End Interface

    <Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), _
     InterfaceType(ComInterfaceType.InterfaceIsIUnknown)>
    Public Interface IAudioEndpointVolume
        Function RegisterControlChangeNotify(ByVal pNotify As IntPtr) As Integer
        Function UnregisterControlChangeNotify(ByVal pNotify As IntPtr) As Integer
        Function GetChannelCount(ByRef pnChannelCount As UInteger) As Integer
        Function SetMasterVolumeLevel(ByVal fLevelDB As Single, _
        ByVal pguidEventContext As Guid) As Integer
        Function SetMasterVolumeLevelScalar(ByVal fLevel As Single, ByVal pguidEventContext As Guid) As Integer
        Function GetMasterVolumeLevel(ByRef pfLevelDB As Single) As Integer
        Function GetMasterVolumeLevelScalar(ByRef pfLevel As Single) As Integer
    End Interface

    Friend Sub SetVolume(ByVal Level As Integer)
        Try
            Dim deviceEnumerator As IMMDeviceEnumerator = _
                                 MMDeviceEnumeratorFactory.CreateInstance()
            Dim speakers As IMMDevice = Nothing
            Dim res As Integer

            Const eRender = 0
            Const eMultimedia = 1

            deviceEnumerator.GetDefaultAudioEndpoint(eRender, eMultimedia, speakers)

            Dim Audio_EndPointVolume As IAudioEndpointVolume = Nothing

            speakers.Activate(GetType(IAudioEndpointVolume).GUID, 0, _
                              IntPtr.Zero, Audio_EndPointVolume)

            Dim ZeroGuid As New Guid()
            res = Audio_EndPointVolume.SetMasterVolumeLevelScalar(Level / 100.0F, ZeroGuid)
        Catch ex As Exception
        End Try
    End Sub

    Friend Function GetVolume() As Integer
        Try
            Dim currentLevel As Single = 0 ' Expressed as a decimal value
            Dim deviceEnumerator As IMMDeviceEnumerator = _
                                 MMDeviceEnumeratorFactory.CreateInstance()
            Dim speakers As IMMDevice = Nothing
            Dim res As Integer

            Const eRender = 0
            Const eMultimedia = 1

            deviceEnumerator.GetDefaultAudioEndpoint(eRender, eMultimedia, speakers)

            Dim Audio_EndPointVolume As IAudioEndpointVolume = Nothing

            speakers.Activate(GetType(IAudioEndpointVolume).GUID, _
                              0, IntPtr.Zero, Audio_EndPointVolume)

            res = Audio_EndPointVolume.GetMasterVolumeLevelScalar(currentLevel)

            Return CInt(100 * currentLevel)  ' Returned as an Integer 0 - 100
        Catch ex As Exception
            Return -1
        End Try
    End Function

End Module

참고로 블로그에 처음 옮길 때 Activator.CreateInstance_, New Guid_, SetMasterVolumeLevelScalar_ 처럼 줄 연속 문자 _ 앞의 공백이 빠진 채로 줄이 나뉘어 있었습니다. VB 에서는 _ 앞에 공백이 없으면 식별자의 일부로 읽혀서 컴파일이 안 됩니다. 위 코드는 그 세 곳을 한 줄로 이어 붙인 것입니다. 다른 곳에서 비슷한 코드를 복사해 올 때도 이 부분에서 자주 걸립니다.

코드가 하는 일

흐름은 세 단계입니다. 먼저 CLSID BCDE0395-... 로 MMDeviceEnumerator COM 객체를 만들고, GetDefaultAudioEndpoint 로 기본 출력 장치(스피커)를 얻습니다. eRender = 0 은 출력 장치, eMultimedia = 1 은 음악·동영상 재생용 역할을 뜻합니다. 그다음 그 장치에서 Activate 로 IAudioEndpointVolume 인터페이스를 꺼내고, 마지막으로 Get/SetMasterVolumeLevelScalar 로 볼륨을 읽거나 씁니다. Scalar 버전은 0.0~1.0 사이의 비율이라서 100 을 곱하고 나누는 변환만 해 주면 됩니다. Scalar 가 붙지 않은 SetMasterVolumeLevel 은 데시벨 단위라 일반적인 볼륨 슬라이더 값과 맞지 않으니 헷갈리지 마세요.

IMMDeviceEnumerator 에 있는 Sub lpVtbl() 은 이상해 보이지만 꼭 필요한 줄입니다. COM 인터페이스는 메서드를 선언 순서(vtable 순서) 로 호출하기 때문에, 실제 인터페이스에서 첫 번째 메서드인 EnumAudioEndpoints 자리를 빈 메서드로 채워 둬야 두 번째인 GetDefaultAudioEndpoint 가 올바른 위치를 가리킵니다. 이 줄을 "안 쓰는 거네" 하고 지우면 엉뚱한 함수가 호출되어 프로그램이 죽습니다. IAudioEndpointVolume 의 메서드 순서도 같은 이유로 바꾸면 안 됩니다.

맨 위의 Option Strict Off 도 이유가 있습니다. Activate 의 마지막 인자는 ByRef ... As Object 인데 실제로는 IAudioEndpointVolume 형식 변수를 넘기고 있습니다. Strict 가 꺼져 있으면 VB 가 호출 후 Object 를 해당 형식으로 변환해서 되돌려 주지만, 켜져 있으면 컴파일 오류가 납니다. 프로젝트 전체가 Option Strict On 이라면 이 모듈 파일에만 Off 를 두거나, Object 변수로 받은 뒤 CType 으로 바꾸도록 고치면 됩니다.

쓰기 전에 알아 둘 점

SetVolume 은 예외를 그냥 삼켜 버립니다. 오디오 장치가 없는 서버나 원격 데스크톱 세션처럼 기본 출력 장치를 못 찾는 환경에서는 아무 일도 일어나지 않고 조용히 넘어가니, 동작하지 않는다 싶으면 Catch 블록에 로그를 먼저 넣어 보세요. 그리고 이 코드는 음소거(Mute) 상태를 건드리지 않습니다. 볼륨을 30 으로 올려도 음소거가 걸려 있으면 소리가 나지 않습니다.

Level 범위 검사도 없어서 0~100 밖의 값을 넣으면 API 가 실패 코드를 돌려줍니다(res 에 담기지만 확인하지 않음). 호출하는 쪽에서 Math.Max(0, Math.Min(100, 값)) 정도로 잘라서 넘기는 게 안전합니다. 기본 출력 장치 기준이라 블루투스 이어폰으로 바뀌면 그 장치의 볼륨이 조절되는데, 함수를 부를 때마다 장치를 새로 얻기 때문에 장치가 바뀌어도 따로 처리할 건 없습니다.