C#利用waveIn实现声音采集
作者:CodeOfCC
前言
之前实现了《C++ 使用waveIn实现声音采集》,后来C#项目也有此功能的需求,直接调用C++封装的dll是可以的。但是wimm这种基于win32 api的库,完全可以直接用C#去调用,将依赖减少到最小。
一、需要的对象及方法
参考《C++ 使用waveIn实现声音采集》,此处不再赘述。
二、整体流程
参考《C++ 使用waveIn实现声音采集》,此处不再赘述。
三、关键实现
此处讲一些与C#相关的点。
1、使用Thread开启线程
笔者一开是实现是使用Task开启线程,由于Task基于线程池可以提高资源利用率,但是这也出现了一些问题。由于录制需要在子线程开启消息循环,多次重复调用录制时,有概率打开同一个线程,就有可能收到上一个录制的数据消息,造成非法内存的读取问题。目前没找到销毁线程中消息循环的方法,只有通过结束线程的方式结束消息循环。所以使用Thread开启线程,才能够解决问题。
_thread = new Thread(() => { _CollectThread();}); _thread.Start();
2、TaskCompletionSource实现异步
因为C#支持async、await机制,这样就可以直接去掉开始和停止两个回调,使用异步实现开始和停止方法。
/// <summary> /// 开始采集,Start和Stop需要成对使用,await可变成同步式,真正开始采集才会返回。 /// 失败会抛出异常,可通过ContinueWith或await获取异常。 /// </summary> public async Task<Task> Start(); /// <summary> /// 停止采集,直接调用是异步,可await等待真正停止 /// 此方法是有可能抛异常的,采集过程中出现的异常,会在此方法中抛出 /// </summary> public async Task<Task> Stop();
调用方式
await wic.Start(); //此行是采集真正开始的时机 await wic.Stop(); //此行是已经停止的时机
由于使用了Thread开启线程,所以我们需要使用其他方式生成Task,在Thread结束后触发Task完成。用过flutter的朋友应该知道这种情况使用Completer就可以,C#中对应Dart的Completer就是TaskCompletionSource。
示例代码如下
public async Task<Task> Start() { TaskCompletionSource? tcsStart=new TaskCompletionSource(); ; _tcs = new TaskCompletionSource(); _thread = new Thread(() => { _CollectThread(tcsStart); _tcs.SetResult();/*线程结束触发完成*/ }); _thread.Start(); //等待开始完成的信号 await tcsStart.Task; return Task.CompletedTask; } void _CollectThread(TaskCompletionSource tcsStart){ while(GetMessage(out msg)!=0) { //接收到Wimm开始消息,触发完成 tcsStart.SetResult(); //接收到Wimm结束消息退出循环结束线程 } } public async Task<Task> Stop() { if (_thread != null) { //发送消息结束线程 _exitFlag = true; PostThreadMessage(_threadId, MM_WIM_CLOSE); //等待线程结束 await _tcs!.Task; _tcs = null; _thread = null; } return Task.CompletedTask; }
3、将指针封装为Stream
通过Wimm采集的音频数据是指针的形式,如果需要转为byte[]这需要使用Marshall进行数据拷贝,为了避免拷贝,数据形式不能是byte[]数组。直接提供指针又不方便使用,笔者采用了Stream的方式提供数据,而且文件流直接支持Stream写入。C#本身有个UmanagedMemoryStream可以支持读取指针的数据,但是需要unsafe上下文,这显然是没必要的(有unsafe上下文,直接通过地址读取数据即可,或者将此功能放dll单独设置unsafe对外提供Stream也不便于管理)。最好的方式还是自己实现一个Stream用于读取指针数据。
完整代码如下:
using System.Runtime.InteropServices; namespace AC { /// <summary> /// 用于读取指针数据的流,内部不会管理指针 /// 由于.net库提供的UnmanagedMemoryStream需要unsafe上下文,所以直接自己封装一个类似功能的stream避开unsafe的使用。 /// </summary> class UMemoryStream : Stream { public override bool CanRead => _access == FileAccess.Read || _access == FileAccess.ReadWrite; public override bool CanSeek => true; public override bool CanWrite => _access == FileAccess.Write || _access == FileAccess.ReadWrite; public override long Length => _len; public override long Position { get; set; } = 0; FileAccess _access; nint _ptr; nint _len; /// <summary> /// 构造方法 /// </summary> /// <param name="ptr">数据地址</param> /// <param name="len">数据长度</param> /// <param name="access"></param> public UMemoryStream(nint ptr, int len, FileAccess access) { _ptr = ptr; _len = len; _access = access; } public override void Flush() { throw new NotSupportedException(); } public override int Read(byte[] buffer, int offset, int count) { if (_ptr == 0) throw new ObjectDisposedException(ToString()); if (!CanRead) throw new NotSupportedException(); var leftCount = _len - Position; if (count > leftCount) { count = (int)leftCount; } if (count > 0) { Marshal.Copy(_ptr + (nint)Position, buffer, offset, count); Position += count; } return count; } public override long Seek(long offset, SeekOrigin origin) { switch (origin) { case SeekOrigin.Begin: Position = offset; break; case SeekOrigin.Current: Position += offset; break; case SeekOrigin.End: Position = _len - offset; break; } return Position; } public override void SetLength(long value) { throw new NotSupportedException(); } public override void Write(byte[] buffer, int offset, int count) { if (_ptr == 0) throw new ObjectDisposedException(ToString()); if (!CanWrite) throw new NotSupportedException(); var leftCount = _len - Position; if (count > leftCount) { count = (int)leftCount; } if (count > 0) { Marshal.Copy(buffer, offset, _ptr + (nint)Position, count); Position += count; } else { throw new ArgumentOutOfRangeException(); } } public override void Close() { _ptr = 0; } } }
四、完整代码
将采集功能封装成一个通用工具,方便在任意地方使用。
1.接口
接口设计如下:
using System.Runtime.InteropServices; using static AC.Winmm; using static AC.User32; using static AC.Kernel32; /************************************************************************ * @Project: AC::WaveInCollector * @Decription: 音频采集工具 * @Verision: v1.0.0.0 * @Author: Xin Nie * @Create: 2023/10/8 09:27:00 * @LastUpdate: 2023/10/24 11:34:00 ************************************************************************ * Copyright @ 2025. All rights reserved. ************************************************************************/ namespace AC { /// <summary> /// 声音采集对象 /// </summary> /// <summary> /// 声音采集对象 ///这是一个功能完整声音采集对象,所有接口通过了测试。 ///非线程安全,所有方法需确保在单线程中调用,即比如:Start和Stop不能在两个线程中同时调用。 /// </summary> public class WaveInCollector : IAsyncDisposable { /// <summary> /// 数据到达事件参数 /// </summary> public class DataArrivedEventArgs : EventArgs { /// <summary> /// 声音格式 /// </summary> public SampleFormat Format { set; get; } /// <summary> /// 声音数据流,为了减少数据拷贝次数,将非托管内存封装成流的形式提供,只读,生命周期为回调方法内。 /// </summary> public Stream Stream { set; get; } } /// <summary> /// 采集数据到达事件 /// </summary> public event EventHandler<DataArrivedEventArgs>? DataArrived; /// <summary> /// 采集速率单位:次/秒 /// 此属性会影响每次输出数据的大小 /// 开始采集前设置有效 /// </summary> public int Frequency { set; get; } = 50; /// <summary> /// 声音格式 /// </summary> public SampleFormat Format { private set; get; } /// <summary> /// 当前设备 /// </summary> public AudioDevice Device { private set; get; } /// <summary> /// 枚举可用的声音采集设备 /// </summary> public static IEnumerable<AudioDevice> AvailableDevices { get; } /// <summary> /// 构造方法 /// </summary> /// <param name="device">音频设备,不能为空</param> /// <param name="SampleFormat">声音格式</param> public WaveInCollector(AudioDevice device, SampleFormat sf); /// <summary> /// 构造方法 /// 如果系统没有任何设备则会抛出异常 /// </summary> /// <param name="deviceId">声音设备Id,0为默认设备</param> /// <param name="SampleFormat">声音格式</param> public WaveInCollector(uint deviceId, SampleFormat sf) : this(GetWaveInDeviceById(deviceId)!, sf) { } /// <summary> /// 开始采集,Start和Stop需要成对使用,await可变成同步式,真正开始采集才会返回。 /// 失败会抛出异常,可通过ContinueWith或await获取异常。 /// </summary> public async Task<Task> Start(); /// <summary> /// 停止采集,直接调用是异步,可await等待真正停止 /// 此方法是有可能抛异常的,采集过程中出现的异常,会在此方法中抛出 /// </summary> public async Task<Task> Stop(); } /// <summary> /// 声音格式 /// </summary> public class SampleFormat { /// <summary> /// 声道数 /// </summary> public ushort Channels { set; get; } /// <summary> /// 采样率 /// </summary> public uint SampleRate { set; get; } /// <summary> /// 位深 /// </summary> public ushort BitsPerSample { set; get; } } /// <summary> /// 音频设备 /// </summary> public class AudioDevice { /// <summary> /// 设备Id /// </summary> public uint Id { set; get; } /// <summary> /// 设备名称 /// </summary> public string Name { set; get; } = ""; /// <summary> /// 声道数 /// </summary> public int Channels { set; get; } /// <summary> /// 支持的格式 /// </summary> public IEnumerable<SampleFormat> SupportedFormats { set; get; } } }
2.具体实现
vs2022 .net6.0 项目,所有win api通过dllimport引入,没有任意额外依赖。
注:winmm不能识别dshow虚拟设备,请根据需要下载资源。
五、使用示例
采集声音并保存为wav文件,其中的WavWriter对象参考《C# 将音频PCM数据封装成wav文件》
方式一
获取可用设备并采集
// See https://aka.ms/new-console-template for more information using AC; try { //获取可用的音频设备 var device = WaveInCollector.AvailableDevices.First(); //创建wav文件 using (var ww = WavWriter.Create("test.wav", device.SupportedFormats!.First().Channels, device.SupportedFormats!.First().SampleRate, device.SupportedFormats!.First().BitsPerSample)) { //初始化录制对象 await using (var wic = new WaveInCollector(device.Id, device.SupportedFormats!.First())) { //由于api限制设备名称不一定全。长度最大32。 Console.WriteLine("设备名称:" + wic.Device.Name); Console.WriteLine("声音格式:Chanels=" + wic.Format.Channels + " SampleRate=" + wic.Format.SampleRate + " BitsPerSample=" + wic.Format.BitsPerSample ); Console.WriteLine("开始录制"); //注册录制事件 wic.DataArrived += (s, e) => { Console.WriteLine("接收数据长度" + e.Stream.Length); //写入文件 ww.Write(e.Stream); }; //开始录制 await wic.Start(); //录制10s结束 await Task.Delay(10000); Console.WriteLine("录制完成"); } } } catch (Exception e) { Console.WriteLine(e.Message); }
方式二
指定设备下标和声音格式
// See https://aka.ms/new-console-template for more information using AC; try { //创建wav文件 using (var ww = WavWriter.Create("test.wav", 2, 44100, 16)) { //初始化录制对象 await using (var wic = new WaveInCollector(0, new SampleFormat() { Channels = 2, SampleRate = 44100, BitsPerSample = 16 })) { //由于api限制设备名称不一定全。长度最大32。 Console.WriteLine("设备名称:" + wic.Device.Name); Console.WriteLine("声音格式:Chanels=" + wic.Format.Channels + " SampleRate=" + wic.Format.SampleRate + " BitsPerSample=" + wic.Format.BitsPerSample ); Console.WriteLine("开始录制"); //注册录制事件 wic.DataArrived += (s, e) => { Console.WriteLine("接收数据长度" + e.Stream.Length); //写入文件 ww.Write(e.Stream); }; //开始录制 await wic.Start(); //录制10s结束 await Task.Delay(10000); Console.WriteLine("录制完成"); } } } catch (Exception e) { Console.WriteLine(e.Message); }
效果预览
总结
实现waveIn声音采集虽然核心部分和C++一样,但是对于接口的设计以及调用流程都有很大的不同,尤其是C#的异步可以简化调用,使得接口变得很简洁,而且通过disposable又可以和using配合省去Stop的调用。但唯一比较麻烦的地方就是内存的互操作,尤其是音频数据缓存的读取和写入,在非unsafe的环境下会多一次拷贝。总的来说,这个功能在C#中实现还是有用的,调用简单而且没有额外依赖。
以上就是C#利用waveIn实现声音采集的详细内容,更多关于C# waveIn声音采集的资料请关注脚本之家其它相关文章!