通过WinAPI播放PCM声音

Posted TianFang

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了通过WinAPI播放PCM声音相关的知识,希望对你有一定的参考价值。

在Windows平台上,播放PCM声音使用的API通常有如下两种。

  • waveOut and waveIn:传统的音频MMEAPI,也是使用的最多的
  • xAudio2:C++/COM API,主要针对游戏开发,是DirectSound的基础

在Windows Vista以后,推出了更加强大的WASAPI ,并用WASAPI封装了MME以及DirectSound API

对于前面的两个API,在.net平台下有如下封装:

WSAPI可能由于更加复杂,没有什么比较完善的封装,codeproject上有篇文章介绍了如何简单的封装WSAPI: Recording and playing PCM audio on Windows 8 (VB)

最近一个项目中使用到了PCM文件的播放,本来想用NAudio实现的,但使用过程中发现它自己提供的BlockAlignReductionStream播放实时数据是效果不是蛮好(方法可以参考这篇文章),总是有一些卡顿的现象。

究其原因是其Buffer的机制,要求每次都填充满buffer,对于文件播放这个不是问题,但对于实时pcm数据,buffer过大播放的时候得不到足够的数据,buffer过小丢数据的情况。

于是,我便研究了一下微软的MMEAPI,官方文档:Using Waveform and Auxiliary Audio。发现MMEAPI也并不复杂,一个简单的示例如下 

技术分享图片
#include <Windows.h>
#include <stdio.h>
#pragma comment(lib, "winmm.lib")
 
int main()
{
    const int buf_size = 1024 * 1024 * 30;
    char* buf = new char[buf_size];
 
    FILE* thbgm; //文件
 
    fopen_s(&thbgm, R"(r:
e_sample.pcm)", "rb");
    fread(buf, sizeof(char), buf_size, thbgm); //预读取文件
    fclose(thbgm);
 
    WAVEFORMATEX wfx = {0};
    wfx.wFormatTag = WAVE_FORMAT_PCM; //设置波形声音的格式
    wfx.nChannels = 2;            //设置音频文件的通道数量
    wfx.nSamplesPerSec = 44100; //设置每个声道播放和记录时的样本频率
    wfx.wBitsPerSample = 16;    //每隔采样点所占的大小
 
    wfx.nBlockAlign = wfx.nChannels * wfx.wBitsPerSample / 8;
    wfx.nAvgBytesPerSec = wfx.nBlockAlign * wfx.nSamplesPerSec;
  
    HANDLE wait = CreateEvent(NULL, 0, 0, NULL);
    HWAVEOUT hwo;
    waveOutOpen(&hwo, WAVE_MAPPER, &wfx, (DWORD_PTR)wait, 0L, CALLBACK_EVENT); //打开一个给定的波形音频输出装置来进行回放
 
    int data_size = 20480;
    char* data_ptr = buf;
    WAVEHDR wh;
 
    while (data_ptr - buf < buf_size)
    {
        //这一部分需要特别注意的是在循环回来之后不能花太长的时间去做读取数据之类的工作,不然在每个循环的间隙会有“哒哒”的噪音
        wh.lpData = data_ptr;
        wh.dwBufferLength = data_size;
        wh.dwFlags = 0L;
        wh.dwLoops = 1L;
 
        data_ptr += data_size;
 
        waveOutPrepareHeader(hwo, &wh, sizeof(WAVEHDR)); //准备一个波形数据块用于播放
        waveOutWrite(hwo, &wh, sizeof(WAVEHDR)); //在音频媒体中播放第二个函数wh指定的数据
 
        WaitForSingleObject(wait, INFINITE); //等待
    }
    waveOutClose(hwo);
    CloseHandle(wait);
 
    
    return 0;
}
View Code

这里是首先预读pcm文件到内存,然后通过事件回调的方式同步写入声音数据。 整个播放过程大概也就用到了五六个API,主要过程如下:

 

设置音频参数

音频参数定义在一个WAVEFORMATEX对象中,这里只介绍PCM的设置方法,主要设置声道数、采样率、和采样位数。

WAVEFORMATEX    wfx = { 0 };
wfx.wFormatTag = WAVE_FORMAT_PCM;    //
设置波形声音的格式
wfx.nChannels = 2;                    //
设置音频文件的道数量
wfx.nSamplesPerSec = 44100;            //
设置每个声道播放和记录时的样本频率
wfx.wBitsPerSample = 16;            //
每隔采样点所占的大小

除此之外,还需要设置两个参数nBlockAlign和nAvgBytesPerSec。对于PCM,它们的计算公式如下:

wfx.nBlockAlign = wfx.nChannels * wfx.wBitsPerSample / 8; 
wfx.nAvgBytesPerSec = wfx.nBlockAlign * wfx.nSamplesPerSec

更多信息请参看MSDN文档:
https://msdn.microsoft.com/en-us/library/windows/desktop/dd757713(v=vs.85).aspx

 

打开音频输出

打开音频输出需要定义一个HWAVEOUT对象,它代表一个波形对象,通过waveOutOpen函数打开它。

HWAVEOUT hwo;
waveOutOpen(&hwo, WAVE_MAPPER, &wfx, (DWORD_PTR)wait, 0L, CALLBACK_EVENT); 

这个函数前三个参数分别是波形对象,输出设备(WAVE_MAPPER为-1,表示默认输出设备),音频参数。 后面三个参数分别是回调相关参数,因为音频数据一次只写入一小段,播放是由系统在另一个线程中进行的,当数据播放完成后,需要通过回调的方式通知写入新数据。

MMEAPI支持多种回调方式。具体参看MSDN文档: waveOutOpen function。具体常见的回调方式有如下几种:

  • CALLBACK_NULL        不回调,需要主动掌握写入数据时机,常用于实时音频流
  • CALLBACK_EVENT        需要数据时写事件,在另外一个独立的线程上等待该事件写入数据
  • CALLBACK_FUNCTION        需要数据时执行回调函数,在回调函数中写入数据

这里是示例通过事件的方式回调的

 

写入音频数据

音频的播放操作是一个生产者消费者模型,调用waveOutOpen后,系统会在后台启动一个播放线程(WinForm程序也可以设置为使用UI线程)。当需要数据时,调用回调函数,写入相应的数据。

首先定义一个WAVEHDR对象:

int data_size = 20480;
char* data_ptr = buf;
WAVEHDR wh;

每次写入的操作过程如下:

wh.lpData = data_ptr;
wh.dwBufferLength = data_size;
wh.dwFlags = 0L;
wh.dwLoops = 1L;

data_ptr += data_size;

waveOutPrepareHeader(hwo, &wh, sizeof(WAVEHDR)); //
准备一个波形数据块用于播放
waveOutWrite(hwo, &wh, sizeof(WAVEHDR)); //
在音频媒体中播放第二个函数wh指定的数据

写入主要是通过两个函数waveOutPrepareHeaderwaveOutWrite进行。这里有两个地方需要注意

  1. 每次写入data_size不要太小,太小了会出现声音不流畅
  2. 从它调用回调到写入的时间间隔不能过长,否则会出现声音断流而出现的哒哒声。

这两个地方的原因实际上都是一个,消费者线程没有足够的数据。要解决这个问题需要采取缓冲模型,对数据源预读。

另外,写入操作waveOutPrepareHeader和waveOutWrite这两个函数是并不要求一定非要在等待通知后才执行的,当写入的速度和播放的速度不一致时,出现声音快进会慢速播放现象。

 

关闭音频输出

关闭音频输出只需要使用接口即可。

waveOutClose(hwo);

 

.net接口封装

了解各接口功能后,自己封装一个也比较简单了。用起来也方便多了。

 WinAPI封装:

技术分享图片
    using HWAVEOUT = IntPtr;

    class winmm
    {
        [StructLayout(LayoutKind.Sequential)]
        public struct WAVEFORMATEX
        {
            /// <summary>
            /// 波形声音的格式
            /// </summary>
            public WaveFormat wFormatTag;

            /// <summary>
            /// 音频文件的通道数量
            /// </summary>
            public UInt16 nChannels; /* number of channels (i.e. mono, stereo...) */

            /// <summary>
            /// 采样频率
            /// </summary>
            public UInt32 nSamplesPerSec; /* sample rate */

            /// <summary>
            /// 每秒缓冲区
            /// </summary>
            public UInt32 nAvgBytesPerSec; /* for buffer estimation */


            public UInt16 nBlockAlign;    /* block size of data */
            public UInt16 wBitsPerSample; /* number of bits per sample of mono data */
            public UInt16 cbSize;         /* the count in bytes of the size of */
        }

        [StructLayout(LayoutKind.Sequential)]
        public struct WAVEHDR
        {
            /// <summary>
            /// 缓冲区指针
            /// </summary>
            public IntPtr lpData;

            /// <summary>
            /// 缓冲区长度
            /// </summary>
            public UInt32 dwBufferLength;
            public UInt32 dwBytesRecorded; /* used for input only */
            public IntPtr dwUser;          /* for client‘s use */

            /// <summary>
            /// 设置标志
            /// </summary>
            public UInt32 dwFlags; 

            /// <summary>
            /// 循环控制
            /// </summary>
            public UInt32 dwLoops; 

            /// <summary>
            /// 保留字段
            /// </summary>
            public IntPtr lpNext;  

            /// <summary>
            /// 保留字段
            /// </summary>
            public IntPtr reserved;
        }


        [Flags]
        public enum WaveOpenFlags
        {
            CALLBACK_NULL     = 0,
            CALLBACK_FUNCTION = 0x30000,
            CALLBACK_EVENT    = 0x50000,
            CallbackWindow    = 0x10000,
            CallbackThread    = 0x20000,
        }

        public enum WaveMessage
        {
            WIM_OPEN  = 0x3BE,
            WIM_CLOSE = 0x3BF,
            WIM_DATA  = 0x3C0,
            WOM_CLOSE = 0x3BC,
            WOM_DONE  = 0x3BD,
            WOM_OPEN  = 0x3BB
        }


        [Flags]
        public enum WaveHeaderFlags
        {
            WHDR_BEGINLOOP = 0x00000004,
            WHDR_DONE      = 0x00000001,
            WHDR_ENDLOOP   = 0x00000008,
            WHDR_INQUEUE   = 0x00000010,
            WHDR_PREPARED  = 0x00000002
        }

        public enum WaveFormat : ushort
        {
            WAVE_FORMAT_PCM = 0x0001,
        }


        /// <summary>
        /// 默认设备
        /// </summary>
        public static IntPtr WAVE_MAPPER { get; } = (IntPtr)(-1);

        public delegate void WaveCallback(IntPtr hWaveOut, WaveMessage message, IntPtr dwInstance, WAVEHDR wavhdr,
                                          IntPtr dwReserved);

        [DllImport("winmm.dll")]
        public static extern int waveOutOpen(out HWAVEOUT hWaveOut,   IntPtr uDeviceID,  in WAVEFORMATEX lpFormat,
                                             WaveCallback dwCallback, IntPtr dwInstance, WaveOpenFlags   dwFlags);

        [DllImport("winmm.dll")]
        public static extern int waveOutOpen(out HWAVEOUT hWaveOut,   IntPtr uDeviceID,  in WAVEFORMATEX lpFormat,
                                             IntPtr       dwCallback, IntPtr dwInstance, WaveOpenFlags   dwFlags);

        [DllImport("winmm.dll")]
        public static extern int waveOutSetVolume(HWAVEOUT hwo, ushort dwVolume);

        [DllImport("winmm.dll")]
        public static extern int waveOutClose(in HWAVEOUT hWaveOut);

        [DllImport("winmm.dll")]
        public static extern int waveOutPrepareHeader(HWAVEOUT hWaveOut, in WAVEHDR lpWaveOutHdr, int uSize);

        [DllImport("winmm.dll")]
        public static extern int waveOutUnprepareHeader(HWAVEOUT hWaveOut, in WAVEHDR lpWaveOutHdr, int uSize);

        [DllImport("winmm.dll")]
        public static extern int waveOutWrite(HWAVEOUT hWaveOut, in WAVEHDR lpWaveOutHdr, int uSize);
    }

    class kernel32
    {
        [DllImport("kernel32.dll")]
        public static extern IntPtr CreateEvent(IntPtr lpEventAttributes, bool bManualReset, bool bInitialState, string lpName);

        [DllImport("kernel32.dll")]
        public static extern int WaitForSingleObject(IntPtr hHandle, int dwMilliseconds);

        [DllImport("kernel32.dll")]
        public static extern bool CloseHandle(IntPtr hHandle);
    }
View Code

PCM播放器:

技术分享图片
    /// <summary>
    /// Pcm播放器
    /// </summary>
    public unsafe class PcmPlayer
    {
        /// <param name="channels">声道数目</param>
        /// <param name="sampleRate">采样频率</param>
        /// <param name="sampleSize">采样大小(bits)</param>
        public PcmPlayer(int channels, int sampleRate, int sampleSize)
        {
            _wfx = new winmm.WAVEFORMATEX
            {
                wFormatTag     = winmm.WaveFormat.WAVE_FORMAT_PCM,
                nChannels      = (ushort)channels,
                nSamplesPerSec = (ushort)sampleRate,
                wBitsPerSample = (ushort)sampleSize
            };

            _wfx.nBlockAlign     = (ushort)(_wfx.nChannels * _wfx.wBitsPerSample / 8);
            _wfx.nAvgBytesPerSec = _wfx.nBlockAlign * _wfx.nSamplesPerSec;
        }

        winmm.WAVEFORMATEX _wfx;
        IntPtr       _hwo;

        /// <summary>
        /// 以事件回调的方式打开设备
        /// </summary>
        /// <param name="waitEvent"></param>
        public void OpenEvent(IntPtr waitEvent)
        {
            winmm.waveOutOpen(out _hwo, winmm.WAVE_MAPPER, _wfx, waitEvent, IntPtr.Zero, winmm.WaveOpenFlags.CALLBACK_EVENT);
            Debug.Assert(_hwo != IntPtr.Zero);
        }

        public void OpenNone()
        {
            winmm.waveOutOpen(out _hwo, winmm.WAVE_MAPPER, _wfx, IntPtr.Zero, IntPtr.Zero, winmm.WaveOpenFlags.CALLBACK_NULL);
            Debug.Assert(_hwo != IntPtr.Zero);
        }


        winmm.WAVEHDR _wh;
        public void WriteData(ReadOnlyMemory<byte> buffer)
        {
            var hwnd = buffer.Pin();

            _wh.lpData         = (IntPtr)hwnd.Pointer;
            _wh.dwBufferLength = (uint)buffer.Length;
            _wh.dwFlags        = 0;
            _wh.dwLoops        = 1;

            winmm.waveOutPrepareHeader(_hwo, _wh, sizeof(winmm.WAVEHDR)); //准备一个波形数据块用于播放
            winmm.waveOutWrite(_hwo, _wh, sizeof(winmm.WAVEHDR));         //在音频媒体中播放第二个函数wh指定的数据
            hwnd.Dispose();
        }

        public void Dispose()
        {
            winmm.waveOutPrepareHeader(_hwo, _wh, sizeof(winmm.WAVEHDR));
            winmm.waveOutClose(_hwo);
            _hwo = IntPtr.Zero;
        }
    }

    public class WaitObject : IDisposable
    {

        public IntPtr Hwnd { get; set; }

        public WaitObject()
        {
            Hwnd = kernel32.CreateEvent(IntPtr.Zero, false, false, null);
        }

        public void Wait()
        {
            kernel32.WaitForSingleObject(Hwnd, -1);
        }

        public void Dispose()
        {
            kernel32.CloseHandle(Hwnd);
            Hwnd = IntPtr.Zero;
        }
    }
View Code

 

以上是关于通过WinAPI播放PCM声音的主要内容,如果未能解决你的问题,请参考以下文章

SoundPlayer 不播放任何捆绑的 Windows 声音 PCM wav 文件

在纯 WinAPI 中从内存缓冲区播放声音

如何正确播放已解码的内存PCM与Oboe?

同时播放两种声音的最简单方法(c++ winapi)

System.InvalidOperationException:“声音 API 仅支持播放 PCM 波形文件。”

[Matlab]播放PCM原始声音文件