用纯.NET开发并制作一个智能桌面机器人(六):使用.NET开发一个跨平台功能完善的AI语音对话客户端
<h2 id="前言">前言</h2><p>前面几篇文章已经把机器人硬件控制部分的开发讲得差不多了,包括屏幕控制、舵机驱动、语音交互等功能。但是之前的外形太过简单,可动角度不够多,所以我就新改进了一个版本,叫VerdiBot(阿荫),详细视频介绍地址请点击链接。</p>
<p>ESP32社区最火的AI对话机器人非小智AI莫属了,所以为了让自己做的机器人对话部分也足够的生动我就重新实现了一个.NET版本的小智客户端,打算后期集成更多的功能,并整理成了一个完整的开源项目——<strong>Verdure Assistant(绿荫助手)</strong>,这是一个基于.NET 9.0的多平台AI语音助手,支持Windows桌面、Android移动端、命令行以及Web API等多种使用方式。</p>
<p>这篇文章主要是给大家讲讲这个对话机器人项目的一些代码,方便想尝试的小伙伴快速上手体验。项目代码已经开源了,大家可以自己研究,遇到问题也欢迎提Issue讨论。</p>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202510/1690009-20251004141039278-682730428.jpg" alt="机器人图片" loading="lazy"></p>
<p><strong>GitHub项目地址</strong>:https://github.com/maker-community/Verdure.Assistant</p>
<h2 id="问题解答">问题解答</h2>
<p><strong>Q: 之前为什么特意做树莓派wifi配网的功能?</strong></p>
<p>A: 之前的博客有网友说我浪费生命开发wifi配网功能,我在评论区也有讲过原因,现在我在这里再讲一遍,因为有时候我们拿着设备到新环境的时候,并不能时刻有可用的显示器和鼠标键盘,但是又需要联网,这时就可以使用wifi配网了。然后ssh连接到设备上就可以像服务器一样控制了。</p>
<p><strong>Q: 支持哪些AI服务?</strong></p>
<p>A: 目前主要对接的是小智AI服务,后续计划支持更多AI服务的接入,包括OpenAI等。项目采用了抽象设计,扩展起来比较方便。</p>
<p><strong>Q: 项目使用什么技术栈?</strong></p>
<p>A: 核心使用.NET 9.0,跨平台UI用.NET MAUI,Windows桌面使用的WinUI 3。网络音频编解码用的OpusSharp库,音频录制播放使用的最近社区刚有人开源的的SoundFlow库,这个库功能完善,使用方便,并且内置了多种音频格式解码的播放,所以我用它替换了之前的PortAudioSharp2,网络通信基于WebSocket和MQTT(未测试)。详细的技术点在GitHub的README里都有说明。</p>
<p><strong>Q: 为什么要重新实现这个项目?</strong><br>
A: 目前小智AI机器人有免费的服务端可以使用,而且整个架构都很优雅,对比我之前的实现优点很多,所以重新实现一个客户端对于用户体验有很大的帮助,并且协议是公开的,以后如果想自己拓展实现服务端也是很轻松的。</p>
<h2 id="项目整体架构">项目整体架构</h2>
<h3 id="目录结构">目录结构</h3>
<p>项目采用清晰的分层架构,便于理解和扩展:</p>
<pre><code>Verdure.Assistant/
├── src/ # 源代码
│ ├── Verdure.Assistant.Core/ # 核心库(音频、网络、服务)
│ ├── Verdure.Assistant.ViewModels/ # 共享视图模型(MVVM)
│ ├── Verdure.Assistant.Console/ # 控制台应用
│ ├── Verdure.Assistant.WinUI/ # WinUI桌面应用
│ ├── Verdure.Assistant.MAUI/ # MAUI移动应用
│ └── Verdure.Assistant.Api/ # Web API服务
├── tests/ # 测试项目
├── docs/ # 技术文档
└── scripts/ # 构建脚本
</code></pre>
<p><strong>GitHub项目地址</strong>:https://github.com/maker-community/Verdure.Assistant</p>
<h3 id="核心功能模块">核心功能模块</h3>
<ul>
<li>
<p><strong>语音交互模块</strong>:使用微软的语音认知服务的关键词唤醒,加载关键词唤醒模型文件不需要Azure订阅("你好小电"/"你好小娜")</p>
<p>src/Verdure.Assistant.Core/Services/WakeWords/KeywordSpottingService.cs</p>
</li>
<li>
<p><strong>音频处理模块</strong>:Opus编解码、SoundFlow音频播放、跨平台音频录制</p>
<p>src/Verdure.Assistant.Core/Services/Audio/AudioDataDistributor.cs<br>
src/Verdure.Assistant.Core/Services/Audio/OpusSharpAudioCodec.cs<br>
src/Verdure.Assistant.Core/Services/Audio/SoundFlowAudioPlayer.cs<br>
src/Verdure.Assistant.Core/Services/Audio/SoundFlowAudioRecorder.cs</p>
</li>
<li>
<p><strong>网络通信模块</strong>:WebSocket实时通信、MQTT物联网协议</p>
<p>src/Verdure.Assistant.Core/Services/Protocols/WebSocketClient.cs</p>
</li>
<li>
<p><strong>状态管理模块</strong>:设备状态机、会话状态控制</p>
<p>src/Verdure.Assistant.Core/Services/StateMachine/ConversationStateMachine.cs<br>
src/Verdure.Assistant.Core/Services/StateMachine/ConversationStateMachineContext.cs</p>
</li>
<li>
<p><strong>音乐播放模块</strong>:集成酷狗/酷我API、在线播放和缓存</p>
<p>src/Verdure.Assistant.Core/Services/KuwoMusicService</p>
</li>
</ul>
<h2 id="-应用截图与演示">📸 应用截图与演示</h2>
<h3 id="️-winui-桌面应用">🖥️ WinUI 桌面应用</h3>
<p align="center">
<img src="https://img2024.cnblogs.com/blog/1690009/202510/1690009-20251004143644892-1920380335.jpg" alt="WinUI Application Screenshot - 点击查看演示视频" width="800">
</p>
<p><strong>📹 演示视频:点击在新标签页播放 ↗</strong></p>
<p><em>现代化的 Windows 桌面应用界面,支持语音交互和实时状态显示</em></p>
<hr>
<h3 id="-maui-移动应用android">📱 MAUI 移动应用(Android)</h3>
<p align="center">
<img src="https://img2024.cnblogs.com/blog/1690009/202510/1690009-20251004143748226-1940939787.jpg" alt="MAUI Application Screenshot - 点击查看演示视频" width="800">
</p>
<p><strong>📹 演示视频:点击在新标签页播放 ↗</strong></p>
<p><em>基于 .NET MAUI 的 Android 移动应用,支持后台语音处理和音乐播放</em></p>
<hr>
<h3 id="-maui-安卓手表应用android-watch">⌚ MAUI 安卓手表应用(Android Watch)</h3>
<p align="center">
<img src="https://img2024.cnblogs.com/blog/1690009/202510/1690009-20251004143842357-1785643286.jpg" alt="MAUI Android Watch Application Screenshot - 点击查看演示视频" width="800">
</p>
<p><strong>📹 演示视频:点击在新标签页播放 ↗</strong></p>
<p><em>基于 .NET MAUI 的安卓手表应用,适配圆形/方形表盘,支持语音助手核心功能</em></p>
<hr>
<h3 id="-web-api-服务">💻 Web API 服务</h3>
<p align="center">
<img src="https://img2024.cnblogs.com/blog/1690009/202510/1690009-20251004143940207-861899980.jpg" alt="Console Application Screenshot - 点击查看演示视频" width="800">
</p>
<p><strong>📹 演示视频:点击在新标签页播放 ↗</strong></p>
<p><em>适合树莓派机器人和普通的测试使用</em></p>
<h2 id="快速开始">快速开始</h2>
<h3 id="环境准备">环境准备</h3>
<h4 id="基础要求">基础要求</h4>
<ul>
<li><strong>.NET 9.0 SDK</strong> - 下载地址</li>
<li><strong>Visual Studio 2022 (17.8+)</strong> 或 <strong>Visual Studio Code</strong></li>
</ul>
<h3 id="克隆项目">克隆项目</h3>
<pre><code class="language-bash">git clone https://github.com/maker-community/Verdure.Assistant.git
cd Verdure.Assistant
</code></pre>
<h2 id="各平台使用指南">各平台使用指南</h2>
<h3 id="1-windows桌面版winui">1. Windows桌面版(WinUI)</h3>
<h4 id="运行方式">运行方式</h4>
<p>在Visual Studio中直接设置为启动项目运行。</p>
<h4 id="使用流程">使用流程</h4>
<ol>
<li>启动应用后,界面会显示连接状态</li>
<li>如果没有在小智后台绑定,会提示进行绑定</li>
<li>绑定完成说出<strong>你好小电</strong>开启对话</li>
<li>说再见会再次进入等待状态</li>
</ol>
<h4 id="功能特性">功能特性</h4>
<ul>
<li><strong>自动模式</strong>:自动持续监听,无需重复唤醒</li>
<li><strong>实时状态显示</strong>:连接状态、语音识别状态可视化</li>
<li><strong>音乐控制</strong>:搜索、播放、暂停音乐</li>
<li><strong>主题切换</strong>:支持深色/浅色主题</li>
</ul>
<h3 id="2-android移动版maui">2. Android移动版(MAUI)</h3>
<h4 id="运行方式-1">运行方式</h4>
<p>使用Visual Studio打开解决方案,选择Android设备或模拟器:</p>
<h4 id="使用流程-1">使用流程</h4>
<ol>
<li>安装APK到Android设备</li>
<li>授予<strong>录音</strong>和<strong>通知</strong>权限</li>
<li>使用唤醒词开启对话</li>
</ol>
<h3 id="3-命令行版console">3. 命令行版(Console)</h3>
<h4 id="运行方式-2">运行方式</h4>
<pre><code class="language-bash">cd src/Verdure.Assistant.Console
dotnet restore
dotnet run
</code></pre>
<h4 id="使用场景">使用场景</h4>
<ul>
<li>服务器端部署(Linux/Windows Server)</li>
<li>开发调试和测试</li>
<li>查看详细日志输出</li>
<li>自动化脚本集成</li>
</ul>
<h3 id="4-web-api服务树莓派服务器">4. Web API服务(树莓派/服务器)</h3>
<h4 id="运行方式-3">运行方式</h4>
<pre><code class="language-bash">cd src/Verdure.Assistant.Api
dotnet restore
dotnet run
</code></pre>
<h4 id="主要api端点">主要API端点</h4>
<p><strong>音乐相关:</strong></p>
<pre><code class="language-bash"># 搜索音乐
GET /api/music/search?songName=青花瓷
# 播放音乐
POST /api/music/search-and-play
Content-Type: application/json
{"songName": "青花瓷"}
# 播放控制
POST /api/music/pause
POST /api/music/resume
POST /api/music/stop
</code></pre>
<h4 id="树莓派部署">树莓派部署</h4>
<p>适合部署在树莓派等嵌入式设备上,配合VerdiBot硬件机器人使用。详细部署步骤参考项目中的API文档。</p>
<h2 id="核心技术详解">核心技术详解</h2>
<h3 id="1-会话状态机">1. 会话状态机</h3>
<p>项目使用状态机管理设备状态,主要状态包括:</p>
<ul>
<li><strong>IDLE(空闲)</strong>:等待唤醒</li>
<li><strong>LISTENING(监听)</strong>:正在录音</li>
<li><strong>SPEAKING(说话)</strong>:播放回复</li>
</ul>
<p>状态转换逻辑清晰,避免混乱的条件判断。</p>
<p>核心代码如下:</p>
<p>请求状态变更代码:</p>
<pre><code class="language-csharp">/// <summary>
/// 请求状态转换
/// </summary>
/// <param name="trigger">触发事件</param>
/// <param name="context">上下文信息</param>
/// <returns>是否成功转换</returns>
public bool RequestTransition(ConversationTrigger trigger, string? context = null)
{
lock (_stateLock)
{
var fromState = _currentState;
var toState = GetNextState(_currentState, trigger);
if (toState == null)
{
_logger?.LogWarning("Invalid state transition: {FromState} -> {Trigger} (context: {Context})",
fromState, trigger, context);
return false;
}
if (fromState == toState.Value)
{
_logger?.LogDebug("State transition ignored (already in target state): {State} -> {Trigger}",
fromState, trigger);
return true;
}
_logger?.LogInformation("State transition: {FromState} -> {ToState} (trigger: {Trigger}, context: {Context})",
fromState, toState.Value, trigger, context);
_currentState = toState.Value;
_previousState = fromState;
// Fire state change event
var eventArgs = new StateTransitionEventArgs
{
FromState = fromState,
ToState = toState.Value,
Trigger = trigger,
Context = context
};
try
{
StateChanged?.Invoke(this, eventArgs);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in state change event handler");
}
return true;
}
}
</code></pre>
<p>状态处理代码:</p>
<pre><code class="language-csharp"> private void InitializeStateMachine()
{
_stateMachine = new ConversationStateMachine();
_stateMachineContext = new ConversationStateMachineContext(_stateMachine)
{
// Set up state machine actions
OnEnterListening = async () =>
{
await StartListeningInternalAsync();
},
OnExitListening = async () =>
{
await StopListeningInternalAsync();
},
OnEnterSpeaking = async () =>
{
// 进入说话状态 - 保持录音以检测用户打断
// 不需要停止录音,继续监听用户的打断
_logger?.LogDebug("进入说话状态,保持录音以检测打断");
await Task.CompletedTask;
},
OnExitSpeaking = async () =>
{
await StopSpeakingInternalAsync();
},
OnEnterIdle = async () =>
{
await EnterIdleStateAsync();
},
OnEnterConnecting = async () =>
{
await EnterConnectingStateAsync();
}
};
// Subscribe to state changes to sync with legacy state property
_stateMachine.StateChanged += OnStateMachineStateChanged;
}
</code></pre>
<h3 id="2-opus编解码">2. Opus编解码</h3>
<p>使用Opus编解码器进行音频压缩,特点:</p>
<ul>
<li><strong>低延迟</strong>:适合实时语音通信</li>
<li><strong>高质量</strong>:保证语音清晰度</li>
<li><strong>带宽节省</strong>:有效降低网络传输压力</li>
</ul>
<p>项目中封装了<code>OpusCodec</code>类,简化了编解码操作。</p>
<p>完整代码如下:</p>
<pre><code class="language-csharp">using OpusSharp.Core;
using Verdure.Assistant.Core.Interfaces;
namespace Verdure.Assistant.Core.Services;
/// <summary>
/// OpusSharp音频编解码器实现
/// </summary>
public class OpusSharpAudioCodec : IAudioCodec
{
private OpusEncoder? _encoder;
private OpusDecoder? _decoder;
private readonly object _lock = new();
private int _currentSampleRate;
private int _currentChannels;
public byte[] Encode(byte[] pcmData, int sampleRate, int channels)
{
lock (_lock)
{
// 验证输入参数是否符合官方规格
if (sampleRate != 16000)
{
System.Console.WriteLine($"警告: 编码采样率 {sampleRate} 不符合官方规格 16000Hz");
}
if (channels != 1)
{
System.Console.WriteLine($"警告: 编码声道数 {channels} 不符合官方规格 1(单声道)");
}
if (_encoder == null || _currentSampleRate != sampleRate || _currentChannels != channels)
{
_encoder?.Dispose();
_encoder = new OpusEncoder(sampleRate, channels, OpusPredefinedValues.OPUS_APPLICATION_AUDIO);
_currentSampleRate = sampleRate;
_currentChannels = channels;
System.Console.WriteLine($"Opus编码器已初始化: {sampleRate}Hz, {channels}声道");
}
try
{
// 计算帧大小 (采样数,不是字节数) - 严格按照官方60ms规格
int frameSize = sampleRate * 60 / 1000; // 对于16kHz = 960样本
// 确保输入数据长度正确 (16位音频 = 2字节/样本)
int expectedBytes = frameSize * channels * 2;
//System.Console.WriteLine($"编码PCM数据: 输入长度={pcmData.Length}字节, 期望长度={expectedBytes}字节, 帧大小={frameSize}样本");
if (pcmData.Length != expectedBytes)
{
//System.Console.WriteLine($"调整PCM数据长度: 从{pcmData.Length}字节到{expectedBytes}字节");
// 调整数据长度或填充零
byte[] adjustedData = new byte;
if (pcmData.Length < expectedBytes)
{
// 数据不足,复制现有数据并填充零
Array.Copy(pcmData, adjustedData, pcmData.Length);
//System.Console.WriteLine($"PCM数据不足,已填充{expectedBytes - pcmData.Length}字节的零");
}
else
{
// 数据过多,截断
Array.Copy(pcmData, adjustedData, expectedBytes);
//System.Console.WriteLine($"PCM数据过多,已截断{pcmData.Length - expectedBytes}字节");
}
pcmData = adjustedData;
}
// 转换为16位短整型数组
short[] pcmShorts = new short;
for (int i = 0; i < pcmShorts.Length && i * 2 + 1 < pcmData.Length; i++)
{
pcmShorts = BitConverter.ToInt16(pcmData, i * 2);
}
// 可选:添加输入音频质量检查
//CheckAudioQuality(pcmData, $"编码输入PCM,长度={pcmData.Length}字节");
// OpusSharp编码 - 使用正确的API
byte[] outputBuffer = new byte; // Opus最大包大小
int encodedLength = _encoder.Encode(pcmShorts, frameSize, outputBuffer, outputBuffer.Length);
//System.Console.WriteLine($"编码结果: 输出长度={encodedLength}字节");
if (encodedLength > 0)
{
// 返回实际编码的数据
byte[] result = new byte;
Array.Copy(outputBuffer, result, encodedLength);
return result;
}
else
{
//System.Console.WriteLine($"编码失败: 返回长度为 {encodedLength}");
}
return Array.Empty<byte>();
}
catch (Exception ex)
{
System.Console.WriteLine($"OpusSharp编码失败: {ex.Message}");
System.Console.WriteLine($"堆栈跟踪: {ex.StackTrace}");
return Array.Empty<byte>();
}
}
}
public byte[] Decode(byte[] encodedData, int sampleRate, int channels)
{
lock (_lock)
{
// 验证输入参数是否符合官方规格
if (sampleRate != 16000)
{
System.Console.WriteLine($"警告: 采样率 {sampleRate} 不符合官方规格 16000Hz");
}
if (channels != 1)
{
System.Console.WriteLine($"警告: 声道数 {channels} 不符合官方规格 1(单声道)");
}
if (_decoder == null || _currentSampleRate != sampleRate || _currentChannels != channels)
{
_decoder?.Dispose();
_decoder = new OpusDecoder(sampleRate, channels);
_currentSampleRate = sampleRate;
_currentChannels = channels;
System.Console.WriteLine($"Opus解码器已初始化: {sampleRate}Hz, {channels}声道");
}
// 检查输入数据有效性
if (encodedData == null || encodedData.Length == 0)
{
System.Console.WriteLine("警告: 接收到空的Opus数据包");
int frameSize = sampleRate * 60 / 1000; // 60ms帧,符合官方规格
byte[] silenceData = new byte;
return silenceData;
}
try
{
// 计算帧大小 (采样数,不是字节数) - 严格按照官方60ms规格
int frameSize = sampleRate * 60 / 1000; // 对于16kHz = 960样本
// 为解码输出分配缓冲区,确保有足够空间
// Opus可能解码出不同长度的帧,所以使用最大可能的帧大小
int maxFrameSize = sampleRate * 120 / 1000; // 最大120ms帧作为安全缓冲
short[] outputBuffer = new short;
System.Console.WriteLine($"解码Opus数据: 输入长度={encodedData.Length}字节, 期望帧大小={frameSize}样本");
// OpusSharp解码 - 使用正确的API,让解码器自动确定帧大小
int decodedSamples = _decoder.Decode(encodedData, encodedData.Length, outputBuffer, maxFrameSize, false);
System.Console.WriteLine($"解码结果: 解码了{decodedSamples}样本");
if (decodedSamples > 0)
{
// 验证解码出的样本数是否合理
if (decodedSamples > maxFrameSize)
{
System.Console.WriteLine($"警告: 解码样本数({decodedSamples})超出最大帧大小({maxFrameSize})");
decodedSamples = maxFrameSize;
}
// 转换为字节数组 - 确保正确的字节序
byte[] pcmBytes = new byte;
for (int i = 0; i < decodedSamples * channels; i++)
{
var bytes = BitConverter.GetBytes(outputBuffer);
pcmBytes = bytes; // 低字节
pcmBytes = bytes; // 高字节
}
// 可选:添加简单的音频质量检查
CheckAudioQuality(pcmBytes, $"解码输出PCM,长度={pcmBytes.Length}字节");
return pcmBytes;
}
else
{
System.Console.WriteLine($"解码失败: 返回的样本数为 {decodedSamples}");
}
// 返回静音数据而不是空数组,保持音频流连续性
int silenceFrameSize = frameSize * channels * 2;
byte[] silenceData = new byte;
System.Console.WriteLine($"返回静音数据: {silenceFrameSize}字节");
return silenceData;
}
catch (Exception ex)
{
System.Console.WriteLine($"OpusSharp解码失败: {ex.Message}");
System.Console.WriteLine($"堆栈跟踪: {ex.StackTrace}");
// 返回静音数据而不是空数组,保持音频流连续性
int frameSize = sampleRate * 60 / 1000; // 60ms帧
byte[] silenceData = new byte;
return silenceData;
}
}
}
/// <summary>
/// 简单的音频质量检查,帮助诊断音频问题
/// </summary>
private void CheckAudioQuality(byte[] pcmData, string context)
{
if (pcmData.Length < 4) return;
// 转换为16位样本进行分析
var samples = new short;
Buffer.BlockCopy(pcmData, 0, samples, 0, pcmData.Length);
// 计算音频统计信息
double sum = 0;
double sumSquares = 0;
short min = short.MaxValue;
short max = short.MinValue;
int zeroCount = 0;
foreach (short sample in samples)
{
sum += sample;
sumSquares += sample * sample;
min = Math.Min(min, sample);
max = Math.Max(max, sample);
if (sample == 0) zeroCount++;
}
double mean = sum / samples.Length;
double rms = Math.Sqrt(sumSquares / samples.Length);
double zeroPercent = (double)zeroCount / samples.Length * 100;
// 检测潜在问题
bool hasIssues = false;
var issues = new List<string>();
// 检查是否全为零(静音)
if (zeroPercent > 95)
{
issues.Add("几乎全为静音");
hasIssues = true;
}
// 检查是否有削波(饱和)
if (max >= 32760 || min <= -32760)
{
issues.Add("可能存在音频削波");
hasIssues = true;
}
// 检查是否有异常的DC偏移
if (Math.Abs(mean) > 1000)
{
issues.Add($"异常的DC偏移: {mean:F1}");
hasIssues = true;
}
// 检查RMS是否异常低(可能的损坏信号)
if (rms < 10 && zeroPercent < 50)
{
issues.Add($"异常低的RMS: {rms:F1}");
hasIssues = true;
} if (hasIssues)
{
//System.Console.WriteLine($"音频质量警告 ({context}): {string.Join(", ", issues)}");
//System.Console.WriteLine($"统计: 样本数={samples.Length}, RMS={rms:F1}, 范围=[{min}, {max}], 零值比例={zeroPercent:F1}%");
}
else
{
//System.Console.WriteLine($"音频质量正常 ({context}): RMS={rms:F1}, 范围=[{min}, {max}]");
}
}
public void Dispose()
{
lock (_lock)
{
_encoder?.Dispose();
_decoder?.Dispose();
}
}
}
</code></pre>
<h3 id="3-soundflow音频框架">3. SoundFlow音频框架</h3>
<p>跨平台音频播放框架:</p>
<p>提供统一的音频播放接口,屏蔽平台差异。</p>
<p>录音初始化代码:</p>
<pre><code class="language-csharp"> private static SoundFlowAudioRecorder? _instance;
private static readonly object _instanceLock = new();
private AudioEngine? _engine;
private AudioCaptureDevice? _captureDevice;
private Recorder? _recorder;
private readonly object _streamLock = new();
private readonly AudioDataDistributor _audioDistributor; // 使用 Channel 优化的音频分发器
private bool _isRecording = false;
private bool _isDisposed = false;
private int _sampleRate = 16000;
private int _channels = 1;
private readonly ILogger<SoundFlowAudioRecorder>? _logger;
// 设备配置 - 优化为低延迟录音
private static readonly MiniAudioDeviceConfig DeviceConfig = new()
{
PeriodSizeInFrames = 960, // 60ms @ 16kHz = 960 samples
PeriodSizeInMilliseconds = 0,
Periods = 3,
NoPreSilencedOutputBuffer = true,
NoClip = false,
NoDisableDenormals = false,
NoFixedSizedCallback = false,
Capture = new DeviceSubConfig
{
ShareMode = ShareMode.Shared
},
Wasapi = new WasapiSettings
{
Usage = WasapiUsage.ProAudio,
NoAutoConvertSRC = false, // 允许自动采样率转换
NoDefaultQualitySRC = false,// 允许高质量重采样
NoAutoStreamRouting = false,
NoHardwareOffloading = false
}
};
// 参考 py-xiaozhi 的事件系统
public event EventHandler<byte[]>? DataAvailable;
public event EventHandler? RecordingStopped;
public bool IsRecording => _isRecording;
private SoundFlowAudioRecorder(ILogger<SoundFlowAudioRecorder>? logger = null)
{
_logger = logger;
_audioDistributor = new AudioDataDistributor(logger);
InitializeAudioEngine();
}
/// <summary>
/// 在构造函数中初始化音频引擎和基础组件
/// </summary>
private void InitializeAudioEngine()
{
try
{
// 在构造时就初始化引擎
_engine = new MiniAudioEngine();
// 显示可用的录音设备(调试模式)
if (_logger != null && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("SoundFlow录音引擎初始化完成");
_logger.LogDebug("可用SoundFlow录音设备:");
for (int i = 0; i < _engine.CaptureDevices.Length; i++)
{
var device = _engine.CaptureDevices;
var marker = device.IsDefault ? " (默认)" : "";
_logger.LogDebug("[{Index}] {Name}{Marker}", i, device.Name, marker);
}
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "初始化SoundFlow录音引擎失败");
throw;
}
}
</code></pre>
<p>播放器初始化代码:</p>
<pre><code class="language-csharp"> private readonly ILogger<SoundFlowAudioPlayer>? _logger;
private AudioEngine? _engine;
private AudioPlaybackDevice? _playbackDevice;
private SoundPlayer? _soundPlayer;
private QueueDataProvider? _dataProvider;
private readonly object _lock = new();
private bool _isPlaying = false;
private bool _isDisposed = false;
private int _sampleRate = 16000;
private int _channels = 1;
// 设备配置 - 优化为更低延迟播放,减少断断续续
private static readonly MiniAudioDeviceConfig DeviceConfig = new()
{
PeriodSizeInFrames = 480, // 30ms @ 16kHz = 480 samples (减少到30ms提高响应性)
PeriodSizeInMilliseconds = 0,
Periods = 4, // 增加到4个周期,提供更好的缓冲
NoPreSilencedOutputBuffer = false,
NoClip = false,
NoDisableDenormals = false,
NoFixedSizedCallback = false,
Playback = new DeviceSubConfig
{
ShareMode = ShareMode.Shared
},
Wasapi = new WasapiSettings
{
Usage = WasapiUsage.ProAudio, // 专业音频模式,降低延迟
NoAutoConvertSRC = false, // 允许自动采样率转换
NoDefaultQualitySRC = false, // 允许高质量重采样
NoAutoStreamRouting = false,
NoHardwareOffloading = false
}
};
public event EventHandler? PlaybackStopped;
public bool IsPlaying => _isPlaying;
public SoundFlowAudioPlayer(ILogger<SoundFlowAudioPlayer>? logger = null)
{
_logger = logger;
// 创建无界通道用于音频数据缓冲,避免阻塞问题
var options = new UnboundedChannelOptions
{
SingleReader = true, // 只有播放任务读取
SingleWriter = false,// 多个来源可能写入音频数据
AllowSynchronousContinuations = false // 避免死锁
};
InitializeAudioEngine();
// 以默认参数预初始化播放设备与播放器,便于后续快速切换/播放
try
{
InitializePlaybackDevice(_sampleRate, _channels);
}
catch (Exception ex)
{
// 预初始化失败不致命,延迟到首次播放再初始化
_logger?.LogWarning(ex, "SoundFlow预初始化失败,将在首次播放时重试");
}
}
/// <summary>
/// 在构造函数中初始化音频引擎和基础组件
/// </summary>
private void InitializeAudioEngine()
{
try
{
// 在构造时就初始化引擎
_engine = new MiniAudioEngine();
// 显示可用的播放设备(调试模式)
if (_logger != null && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("SoundFlow播放引擎初始化完成");
_logger.LogDebug("可用SoundFlow播放设备:");
for (int i = 0; i < _engine.PlaybackDevices.Length; i++)
{
var device = _engine.PlaybackDevices;
var status = device.IsDefault ? " (默认)" : "";
_logger.LogDebug("[{Index}] {Name}{Status}", i, device.Name, status);
}
}
if (_engine.PlaybackDevices.Length == 0)
{
throw new InvalidOperationException("未找到SoundFlow音频播放设备");
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "初始化SoundFlow播放引擎失败");
throw;
}
}
/// <summary>
/// 初始化播放设备(仅在参数变化时调用)
/// </summary>
private void InitializePlaybackDevice(int sampleRate, int channels)
{
if (!ValidateAudioParameters(sampleRate, channels))
{
throw new ArgumentException("Invalid audio parameters");
}
// 如果参数相同且设备已初始化,直接返回
if (_playbackDevice != null && _sampleRate == sampleRate && _channels == channels)
{
return;
}
// 清理现有设备
if (_playbackDevice != null)
{
try
{
_playbackDevice.Stop();
_playbackDevice = null;
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "清理旧播放设备时出错");
}
}
_sampleRate = sampleRate;
_channels = channels;
try
{
// 引擎已在构造函数中初始化
if (_engine == null)
{
throw new InvalidOperationException("SoundFlow引擎未正确初始化");
}
// 修复:统一使用F32格式,与QueueDataProvider保持一致
var format = new AudioFormat
{
SampleRate = sampleRate,
Channels = channels,
Format = SampleFormat.F32// 改为F32,与播放器格式一致
};
_playbackDevice = _engine.InitializePlaybackDevice(null, format, DeviceConfig);
_logger?.LogDebug("已选择SoundFlow播放设备: {DeviceName}", _playbackDevice.Info?.Name ?? "默认设备");
_logger?.LogDebug("播放设备格式: {Format}, {Channels}ch, {SampleRate}Hz",
_playbackDevice.Format.Format, _playbackDevice.Format.Channels, _playbackDevice.Format.SampleRate);
_logger?.LogInformation("SoundFlow音频播放器设备初始化成功: {SampleRate}Hz, {Channels}声道",
sampleRate, channels);
}
catch (Exception ex)
{
throw new Exception($"初始化SoundFlow音频播放设备失败: {ex.Message}", ex);
}
}
/// <summary>
/// 初始化SoundPlayer与QueueDataProvider(仅在参数变化时调用)
/// </summary>
private async Task InitializePlayer(int sampleRate, int channels)
{
if (_engine == null || _playbackDevice == null)
{
throw new InvalidOperationException("SoundFlow引擎或播放设备未初始化");
}
_sampleRate = sampleRate;
_channels = channels;
try
{
// 创建音频格式 - 匹配测试项目的要求
var format = new AudioFormat
{
SampleRate = sampleRate,
Channels = channels,
Format = SampleFormat.F32 // QueueDataProvider使用Float32格式
};
// 清理旧播放器
if (_soundPlayer != null)
{
try
{
_soundPlayer.Stop();
_playbackDevice.MasterMixer.RemoveComponent(_soundPlayer);
_soundPlayer.Dispose();
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "清理旧播放器时出错");
}
}
_dataProvider?.Dispose();
// 创建QueueDataProvider - 专为流式数据设计
_dataProvider = new QueueDataProvider(format);
_dataProvider.EndOfStreamReached += (s, e) =>
{
_logger?.LogDebug("SoundFlow数据提供者已到达流末尾");
PlaybackStopped?.Invoke(this, EventArgs.Empty);
};
// 创建播放器
_soundPlayer = new SoundPlayer(_engine, format, _dataProvider);
// 添加到播放设备的混音器
_playbackDevice.MasterMixer.AddComponent(_soundPlayer);
_logger?.LogDebug("SoundFlow播放器初始化完成: {SampleRate}Hz, {Channels}ch", sampleRate, channels);
await Task.CompletedTask;
}
catch (Exception ex)
{
_logger?.LogError(ex, "SoundFlow播放器初始化错误");
throw;
}
}
</code></pre>
<h3 id="4-关键词唤醒">4. 关键词唤醒</h3>
<p>支持两种唤醒词模型:</p>
<ul>
<li><strong>xiaodian(小电)</strong>:"你好小电"</li>
<li><strong>cortana(小娜)</strong>:"你好小娜"</li>
</ul>
<p>基于音频流实时检测,CPU占用低,响应速度快。</p>
<p>关键词唤醒核心逻辑:</p>
<pre><code class="language-csharp"> /// <summary>
/// 初始化语音配置(离线模式,无需订阅密钥)
/// </summary>
private void InitializeSpeechConfig()
{
try
{
// 创建离线语音配置
// 对于关键词检测,可以使用空的配置,因为我们使用本地.table文件
_speechConfig = SpeechConfig.FromSubscription("dummy", "dummy");
// 设置为离线模式
_speechConfig.SetProperty("SPEECH-UseOfflineRecognition", "true");
_logger?.LogInformation("语音配置初始化成功(离线模式)");
}
catch (Exception ex)
{
_logger?.LogError(ex, "初始化语音配置失败");
_isEnabled = false;
}
}
/// <summary>
/// 启动关键词检测(对应py-xiaozhi的start方法)
/// </summary>
public async Task<bool> StartAsync(IAudioRecorder? audioRecorder = null)
{
if (!_isEnabled)
{
_logger?.LogWarning("关键词检测功能未启用");
return false;
}
if (_isRunning)
{
_logger?.LogWarning("关键词检测已在运行");
return true;
}
try
{
await _semaphore.WaitAsync();
_cancellationTokenSource = new CancellationTokenSource();
// 设置音频源(对应py-xiaozhi的多种启动模式)
if (audioRecorder != null)
{
_audioRecorder = audioRecorder;
_useExternalAudioSource = true;
_logger?.LogInformation("使用外部音频源启动关键词检测");
}
else
{
_useExternalAudioSource = false;
_logger?.LogInformation("使用独立音频模式启动关键词检测");
}
// 加载关键词模型
if (!await LoadKeywordModelsAsync())
{
_logger?.LogError("加载关键词模型失败");
return false;
}
// 配置音频输入 - 使用共享音频流管理器
var audioConfig = await ConfigureSharedAudioInput();
if (audioConfig == null)
{
_logger?.LogError("配置音频输入失败");
return false;
}
// 创建关键词识别器 - 确保每次启动都是全新实例
_keywordRecognizer = new KeywordRecognizer(audioConfig);
// 订阅事件
SubscribeToRecognizerEvents();
// 在后台任务中启动关键词识别,避免阻塞主流程
_ = Task.Run(async () =>
{
try
{
if (_keywordModel != null && _keywordRecognizer != null)
{
await _keywordRecognizer.RecognizeOnceAsync(_keywordModel);
_logger?.LogInformation("关键词识别已启动(后台任务)");
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "关键词识别后台任务异常");
OnErrorOccurred($"关键词识别异常: {ex.Message}");
}
});
_isRunning = true;
_isPaused = false;
_logger?.LogInformation("关键词检测启动成功");
return true;
}
catch (Exception ex)
{
_logger?.LogError(ex, "启动关键词检测失败");
return false;
}
finally
{
_semaphore.Release();
}
}
</code></pre>
<h2 id="常见问题排查">常见问题排查</h2>
<h3 id="树莓派运行webapi服务初始化语音设备失败">树莓派运行WebAPI服务初始化语音设备失败</h3>
<ol>
<li>确认usb声卡已经是默认设备</li>
</ol>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202510/1690009-20251004170719544-824164991.png" alt="img" loading="lazy"></p>
<ol start="2">
<li>如何禁用默认的声卡<br>
通过下面的指令编辑配置文件</li>
</ol>
<pre><code>sudo nano /boot/firmware/config.txt
</code></pre>
<p>注释掉图片上的代码就可以关闭<br>
<img src="https://img2024.cnblogs.com/blog/1690009/202510/1690009-20251004170927409-773905350.png" alt="img" loading="lazy"></p>
<h3 id="连接失败">连接失败</h3>
<ol>
<li>确认网络连接正常</li>
<li>查看防火墙是否阻止了连接</li>
</ol>
<h3 id="音频问题">音频问题</h3>
<ol>
<li>确认麦克风权限已授予</li>
<li>检查音频设备是否正常工作</li>
<li>查看日志中是否有音频相关错误</li>
</ol>
<h3 id="android权限问题">Android权限问题</h3>
<p>在<code>AndroidManifest.xml</code>中确认权限声明:</p>
<pre><code class="language-xml"><uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
</code></pre>
<h2 id="总结">总结</h2>
<p>通过这个项目,我想展示.NET在跨平台开发方面的强大能力。一套核心代码,可以运行在Windows、Android、Linux等多个平台上。</p>
<p>大多数的代码是我指导Github Copilot生成的,要是我个人独自实现的话,应该会花费更多的时间,我个人感觉AI编程目前对于个人开发者来说确实能够帮助很多的,如果生成的质量不好,我们需要调整提示词和更换更厉害的模型,请不要放弃使用。</p>
<p>项目代码开源在GitHub上,文档也比较完善。如果你对.NET跨平台开发或AI语音助手感兴趣,可以下载下来研究研究。遇到问题欢迎提Issue或者在评论区讨论。</p>
<p>目前项目的文档有使用AI进行一些编写,但是我没有进行仔细的校验,请大家以代码实现为准。</p>
<p>希望这篇文章能帮助大家快速上手这个项目。后续我还会继续更新相关的技术细节和开发经验,欢迎持续关注!</p>
<h2 id="参考资源">参考资源</h2>
<ul>
<li><strong>GitHub项目地址</strong>:https://github.com/maker-community/Verdure.Assistant</li>
<li><strong>项目文档站点</strong>:https://verdure-assistant.verdure-hiro.cn/zh/</li>
<li><strong>VerdiBot机器人项目</strong>:https://github.com/maker-community/VerdiBot</li>
<li><strong>创客社区</strong>:https://github.com/maker-community</li>
<li><strong>.NET官方文档</strong>:https://docs.microsoft.com/zh-cn/dotnet/</li>
<li><strong>WinUI 3文档</strong>:https://learn.microsoft.com/windows/apps/winui/winui3/</li>
<li><strong>.NET MAUI文档</strong>:https://learn.microsoft.com/dotnet/maui/</li>
<li><strong>xiaozhi-esp32 ESP32 参考实现</strong>:https://github.com/78/xiaozhi-esp32</li>
<li><strong>py-xiaozhi Python 参考实现</strong>:https://github.com/huangjunsen0406/py-xiaozhi</li>
<li><strong>xiaozhi-sharp</strong>:https://github.com/GreenShadeZhang/xiaozhi-sharp</li>
<li><strong>SoundFlow</strong>:https://github.com/LSXPrime/SoundFlow</li>
<li><strong>本人B站地址:</strong>:https://space.bilibili.com/25228512</li>
</ul>
<hr>
<p><em>本文首发于个人技术博客,转载请注明出处。如果对.NET跨平台开发和IoT感兴趣,欢迎关注我的博客获取更多技术分享!</em></p><br><br>
来源:https://www.cnblogs.com/GreenShade/p/19125465
頁:
[1]