之前实现了《C++ 使用waveIn实现声音采集》,后来C#项目也有此功能的需求,直接调用C++封装的dll是可以的。但是wimm这种基于win32 api的库,完全可以直接用C#去调用,将依赖减少到最小。
参考《C++ 使用waveIn实现声音采集》,此处不再赘述。
参考《C++ 使用waveIn实现声音采集》,此处不再赘述。
此处讲一些与C#相关的点。
笔者一开是实现是使用Task开启线程,由于Task基于线程池可以提高资源利用率,但是这也出现了一些问题。由于录制需要在子线程开启消息循环,多次重复调用录制时,有概率打开同一个线程,就有可能收到上一个录制的数据消息,造成非法内存的读取问题。目前没找到销毁线程中消息循环的方法,只有通过结束线程的方式结束消息循环。所以使用Thread开启线程,才能够解决问题。
_thread = new Thread(() => { _CollectThread();});
_thread.Start();
因为C#支持async、await机制,这样就可以直接去掉开始和停止两个回调,使用异步实现开始和停止方法。
///
/// 开始采集,Start和Stop需要成对使用,await可变成同步式,真正开始采集才会返回。
/// 失败会抛出异常,可通过ContinueWith或await获取异常。
///
public async Task<Task> Start();
///
/// 停止采集,直接调用是异步,可await等待真正停止
/// 此方法是有可能抛异常的,采集过程中出现的异常,会在此方法中抛出
///
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;
}
因为使用了异步实现,结合参考dart的stream,以及C#8.0提供的异步流,可以进一步优化调用方式。首先Start和Stop是可以省略的,直接构造即开始,dispose即结束。然后是将构造和dispose放入同一个上下文,中间通过一个循环yield return返回采集数据。最后通过异步流,实现await foreach调用。
实现示例
///创建音频采集数据流,
public static async IAsyncEnumerable<byte[]> CreateStream()
{
//构造采集对象
//while(true)
//{
//子线程采集
// yield return await 采集数据
//}
//dispose
}
调用方式
await foreach(var i in CreateStream())
{
//i即是采集的数据,需要停止采集则直接break。
}
由于采用了异步流的方式设计,接口有且仅有2个,即:CreateStream方法、和AvailableDevices属性。
接口设计如下:
.net 6.0
/************************************************************************
* @Project: AC::WaveInCollector
* @Decription: 音频采集工具
* @Verision: v1.2.0.0
* @Author: Xin Nie
* @Create: 2023/10/8 09:27:00
* @LastUpdate: 2023/10/30 10:30:00
************************************************************************
* Copyright @ 2025. All rights reserved.
************************************************************************/
namespace AC
{
///
/// 声音采集对象
///
///
/// 声音采集对象
///这是一个功能完整声音采集对象,所有接口通过了测试。
///采样了纯异步实现,接口变得极为简单。只有一个接口:创建异步流(CreateStream)
///需要循环await调用CreateStream获取音频数据(参考dart的stream),如果获取数据频率低于采集频率(比如循环内有耗时操作),内部会缓存数据,长度为10,超过则丢弃。
///私有化了原来的对象的new、start、stop等方法。只提供两个方法:创建异步流CreateStream。以及获取可用设备AvailableDevices。
///这样的做好处是极度简化了调用,避免了手动资源释,循环退出自动就释放了资源。
///
public class WaveInCollector : IAsyncDisposable
{
///
///创建音频采集数据流
///这是一个异步流(async stream),是C#8.0的特性,与dart的stream有点类似。
///参考 https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/generate-consume-asynchronous-stream
/// 用法:
/// await foreach(var i in WaveInCollector.CreateStream())
/// {
/// //i即是采集的数据,需要停止采集则直接break。
/// }
/// 采集过程中遇到错误会抛出异常。
///
/// 设备Id,0为默认设备,可以通过WaveInCollector.AvailableDevices获取可用设备,同一个设备不能同时打开多个
/// 声音格式,null时默认为:2,44100,16
/// 缓冲区长度,即每次获取数据的长度。其值大小会影响采集频率,值越小采集频率越高
/// 采集开始的Completion对象,通过这个对象可以获得采集开始事件
/// 异步流
public static async IAsyncEnumerable<byte[]> CreateStream(uint deviceId = 0, SampleFormat? sf = null, uint bufferLength = 8192, TaskCompletionSource? startedTcs = null);
///
/// 枚举可用的声音采集设备
/// 由于api限制Name长度最大为32
///
public static IEnumerable<AudioDevice> AvailableDevices {get;};
///
/// 异步dispose继承与IAsyncDisposable
///
///
public async ValueTask DisposeAsync();
}
///
/// 声音格式
///
public class SampleFormat
{
///
/// 声道数
///
public ushort Channels { set; get; }
///
/// 采样率
///
public uint SampleRate { set; get; }
///
/// 位深
///
public ushort BitsPerSample { set; get; }
}
///
/// 音频设备
///
public class AudioDevice
{
///
/// 设备Id
///
public uint Id { set; get; }
///
/// 设备名称
///
public string Name { set; get; } = "";
///
/// 声道数
///
public int Channels { set; get; }
///
/// 支持的格式
///
public IEnumerable<SampleFormat> SupportedFormats { set; get; }
}
}
vs2022 .net6.0 项目,所有win api通过dllimport引入,没有任意额外依赖。
注:winmm不能识别dshow虚拟设备,请根据需要下载资源。
https://download.csdn.net/download/u013113678/88483543
采集声音并保存为wav文件,其中的WavWriter对象参考《C# 将音频PCM数据封装成wav文件》
默认设备采集
using AC;
try
{
using (var ww = WavWriter.Create("test.wav", 2, 44100, 16))
await foreach (var i in WaveInCollector.CreateStream())
{
ww.Write(i);
if (ww.WrittenLength / (2 * 44100 * 16 / 8) > 10/*录制时长10s*/)
break;
}
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
获取可用设备并采集,这是个完整的示例基本用到了所有接口元素。
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))
{
//由于api限制设备名称不一定全。长度最大32。
Console.WriteLine("设备名称:" + device.Name);
Console.WriteLine("声音格式:Chanels=" + device.SupportedFormats.First().Channels + " SampleRate=" + device.SupportedFormats.First().SampleRate + " BitsPerSample=" + device.SupportedFormats.First().BitsPerSample);
bool exitFlag = false;
//录制10s后退出
_ = Task.Delay(10000).ContinueWith(o => exitFlag = true);
//tcs对象获取开始事件
TaskCompletionSource tcs = new TaskCompletionSource();
_ = tcs.Task.ContinueWith(o =>/*开始事件*/ Console.WriteLine("录制开始"));
//开始录制
await foreach (var i in WaveInCollector.CreateStream(device.Id, device.SupportedFormats.First(),1024, tcs))
{
Console.WriteLine("接收数据长度" + i.Length);
//数据写入文件
ww.Write(i);
if (exitFlag) break;
}
Console.WriteLine("录制完成");
}
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
以上就是今天要讲的内容,实现waveIn声音采集虽然核心部分和C++一样,但是对于接口的设计以及调用流程都有很大的不同,尤其是C#的异步可以简化调用,使得接口变得很简洁,而且通过disposable又可以和using配合省去Stop的调用。但唯一比较麻烦的地方就是内存的互操作,尤其是音频数据缓存的读取和写入,在非unsafe的环境下会多一次拷贝。总的来说,这个功能在C#中实现还是有用的,调用简单而且没有额外依赖。