用純.NET開發(fā)并制作一個智能桌面機器人(六):使用.NET開發(fā)一個跨平臺功能完善的AI語音對話客戶端
前言
前面幾篇文章已經(jīng)把機器人硬件控制部分的開發(fā)講得差不多了,包括屏幕控制、舵機驅(qū)動、語音交互等功能。但是之前的外形太過簡單,可動角度不夠多,所以我就新改進了一個版本,叫VerdiBot(阿蔭),詳細視頻介紹地址請點擊鏈接。
ESP32社區(qū)最火的AI對話機器人非小智AI莫屬了,所以為了讓自己做的機器人對話部分也足夠的生動我就重新實現(xiàn)了一個.NET版本的小智客戶端,打算后期集成更多的功能,并整理成了一個完整的開源項目——Verdure Assistant(綠蔭助手),這是一個基于.NET 9.0的多平臺AI語音助手,支持Windows桌面、Android移動端、命令行以及Web API等多種使用方式。
這篇文章主要是給大家講講這個對話機器人項目的一些代碼,方便想嘗試的小伙伴快速上手體驗。項目代碼已經(jīng)開源了,大家可以自己研究,遇到問題也歡迎提Issue討論。

GitHub項目地址:https://github.com/maker-community/Verdure.Assistant
問題解答
Q: 之前為什么特意做樹莓派wifi配網(wǎng)的功能?
A: 之前的博客有網(wǎng)友說我浪費生命開發(fā)wifi配網(wǎng)功能,我在評論區(qū)也有講過原因,現(xiàn)在我在這里再講一遍,因為有時候我們拿著設(shè)備到新環(huán)境的時候,并不能時刻有可用的顯示器和鼠標鍵盤,但是又需要聯(lián)網(wǎng),這時就可以使用wifi配網(wǎng)了。然后ssh連接到設(shè)備上就可以像服務(wù)器一樣控制了。
Q: 支持哪些AI服務(wù)?
A: 目前主要對接的是小智AI服務(wù),后續(xù)計劃支持更多AI服務(wù)的接入,包括OpenAI等。項目采用了抽象設(shè)計,擴展起來比較方便。
Q: 項目使用什么技術(shù)棧?
A: 核心使用.NET 9.0,跨平臺UI用.NET MAUI,Windows桌面使用的WinUI 3。網(wǎng)絡(luò)音頻編解碼用的OpusSharp庫,音頻錄制播放使用的最近社區(qū)剛有人開源的的SoundFlow庫,這個庫功能完善,使用方便,并且內(nèi)置了多種音頻格式解碼的播放,所以我用它替換了之前的PortAudioSharp2,網(wǎng)絡(luò)通信基于WebSocket和MQTT(未測試)。詳細的技術(shù)點在GitHub的README里都有說明。
Q: 為什么要重新實現(xiàn)這個項目?
A: 目前小智AI機器人有免費的服務(wù)端可以使用,而且整個架構(gòu)都很優(yōu)雅,對比我之前的實現(xiàn)優(yōu)點很多,所以重新實現(xiàn)一個客戶端對于用戶體驗有很大的幫助,并且協(xié)議是公開的,以后如果想自己拓展實現(xiàn)服務(wù)端也是很輕松的。
項目整體架構(gòu)
目錄結(jié)構(gòu)
項目采用清晰的分層架構(gòu),便于理解和擴展:
Verdure.Assistant/
├── src/ # 源代碼
│ ├── Verdure.Assistant.Core/ # 核心庫(音頻、網(wǎng)絡(luò)、服務(wù))
│ ├── Verdure.Assistant.ViewModels/ # 共享視圖模型(MVVM)
│ ├── Verdure.Assistant.Console/ # 控制臺應(yīng)用
│ ├── Verdure.Assistant.WinUI/ # WinUI桌面應(yīng)用
│ ├── Verdure.Assistant.MAUI/ # MAUI移動應(yīng)用
│ └── Verdure.Assistant.Api/ # Web API服務(wù)
├── tests/ # 測試項目
├── docs/ # 技術(shù)文檔
└── scripts/ # 構(gòu)建腳本
GitHub項目地址:https://github.com/maker-community/Verdure.Assistant
核心功能模塊
-
語音交互模塊:使用微軟的語音認知服務(wù)的關(guān)鍵詞喚醒,加載關(guān)鍵詞喚醒模型文件不需要Azure訂閱("你好小電"/"你好小娜")
src/Verdure.Assistant.Core/Services/WakeWords/KeywordSpottingService.cs
-
音頻處理模塊:Opus編解碼、SoundFlow音頻播放、跨平臺音頻錄制
src/Verdure.Assistant.Core/Services/Audio/AudioDataDistributor.cs
src/Verdure.Assistant.Core/Services/Audio/OpusSharpAudioCodec.cs
src/Verdure.Assistant.Core/Services/Audio/SoundFlowAudioPlayer.cs
src/Verdure.Assistant.Core/Services/Audio/SoundFlowAudioRecorder.cs -
網(wǎng)絡(luò)通信模塊:WebSocket實時通信、MQTT物聯(lián)網(wǎng)協(xié)議
src/Verdure.Assistant.Core/Services/Protocols/WebSocketClient.cs
-
狀態(tài)管理模塊:設(shè)備狀態(tài)機、會話狀態(tài)控制
src/Verdure.Assistant.Core/Services/StateMachine/ConversationStateMachine.cs
src/Verdure.Assistant.Core/Services/StateMachine/ConversationStateMachineContext.cs -
音樂播放模塊:集成酷狗/酷我API、在線播放和緩存
src/Verdure.Assistant.Core/Services/KuwoMusicService
?? 應(yīng)用截圖與演示
??? WinUI 桌面應(yīng)用
?? 演示視頻:點擊在新標簽頁播放 ↗
現(xiàn)代化的 Windows 桌面應(yīng)用界面,支持語音交互和實時狀態(tài)顯示
?? MAUI 移動應(yīng)用(Android)
?? 演示視頻:點擊在新標簽頁播放 ↗
基于 .NET MAUI 的 Android 移動應(yīng)用,支持后臺語音處理和音樂播放
? MAUI 安卓手表應(yīng)用(Android Watch)
?? 演示視頻:點擊在新標簽頁播放 ↗
基于 .NET MAUI 的安卓手表應(yīng)用,適配圓形/方形表盤,支持語音助手核心功能
?? Web API 服務(wù)
?? 演示視頻:點擊在新標簽頁播放 ↗
適合樹莓派機器人和普通的測試使用
快速開始
環(huán)境準備
基礎(chǔ)要求
- .NET 9.0 SDK - 下載地址
- Visual Studio 2022 (17.8+) 或 Visual Studio Code
克隆項目
git clone https://github.com/maker-community/Verdure.Assistant.git
cd Verdure.Assistant
各平臺使用指南
1. Windows桌面版(WinUI)
運行方式
在Visual Studio中直接設(shè)置為啟動項目運行。
使用流程
- 啟動應(yīng)用后,界面會顯示連接狀態(tài)
- 如果沒有在小智后臺綁定,會提示進行綁定
- 綁定完成說出你好小電開啟對話
- 說再見會再次進入等待狀態(tài)
功能特性
- 自動模式:自動持續(xù)監(jiān)聽,無需重復(fù)喚醒
- 實時狀態(tài)顯示:連接狀態(tài)、語音識別狀態(tài)可視化
- 音樂控制:搜索、播放、暫停音樂
- 主題切換:支持深色/淺色主題
2. Android移動版(MAUI)
運行方式
使用Visual Studio打開解決方案,選擇Android設(shè)備或模擬器:
使用流程
- 安裝APK到Android設(shè)備
- 授予錄音和通知權(quán)限
- 使用喚醒詞開啟對話
3. 命令行版(Console)
運行方式
cd src/Verdure.Assistant.Console
dotnet restore
dotnet run
使用場景
- 服務(wù)器端部署(Linux/Windows Server)
- 開發(fā)調(diào)試和測試
- 查看詳細日志輸出
- 自動化腳本集成
4. Web API服務(wù)(樹莓派/服務(wù)器)
運行方式
cd src/Verdure.Assistant.Api
dotnet restore
dotnet run
主要API端點
音樂相關(guān):
# 搜索音樂
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
樹莓派部署
適合部署在樹莓派等嵌入式設(shè)備上,配合VerdiBot硬件機器人使用。詳細部署步驟參考項目中的API文檔。
核心技術(shù)詳解
1. 會話狀態(tài)機
項目使用狀態(tài)機管理設(shè)備狀態(tài),主要狀態(tài)包括:
- IDLE(空閑):等待喚醒
- LISTENING(監(jiān)聽):正在錄音
- SPEAKING(說話):播放回復(fù)
狀態(tài)轉(zhuǎn)換邏輯清晰,避免混亂的條件判斷。
核心代碼如下:
請求狀態(tài)變更代碼:
/// <summary>
/// 請求狀態(tài)轉(zhuǎn)換
/// </summary>
/// <param name="trigger">觸發(fā)事件</param>
/// <param name="context">上下文信息</param>
/// <returns>是否成功轉(zhuǎn)換</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;
}
}
狀態(tài)處理代碼:
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 () =>
{
// 進入說話狀態(tài) - 保持錄音以檢測用戶打斷
// 不需要停止錄音,繼續(xù)監(jiān)聽用戶的打斷
_logger?.LogDebug("進入說話狀態(tài),保持錄音以檢測打斷");
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;
}
2. Opus編解碼
使用Opus編解碼器進行音頻壓縮,特點:
- 低延遲:適合實時語音通信
- 高質(zhì)量:保證語音清晰度
- 帶寬節(jié)省:有效降低網(wǎng)絡(luò)傳輸壓力
項目中封裝了OpusCodec類,簡化了編解碼操作。
完整代碼如下:
using OpusSharp.Core;
using Verdure.Assistant.Core.Interfaces;
namespace Verdure.Assistant.Core.Services;
/// <summary>
/// OpusSharp音頻編解碼器實現(xiàn)
/// </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)
{
// 驗證輸入?yún)?shù)是否符合官方規(guī)格
if (sampleRate != 16000)
{
System.Console.WriteLine($"警告: 編碼采樣率 {sampleRate} 不符合官方規(guī)格 16000Hz");
}
if (channels != 1)
{
System.Console.WriteLine($"警告: 編碼聲道數(shù) {channels} 不符合官方規(guī)格 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
{
// 計算幀大小 (采樣數(shù),不是字節(jié)數(shù)) - 嚴格按照官方60ms規(guī)格
int frameSize = sampleRate * 60 / 1000; // 對于16kHz = 960樣本
// 確保輸入數(shù)據(jù)長度正確 (16位音頻 = 2字節(jié)/樣本)
int expectedBytes = frameSize * channels * 2;
//System.Console.WriteLine($"編碼PCM數(shù)據(jù): 輸入長度={pcmData.Length}字節(jié), 期望長度={expectedBytes}字節(jié), 幀大小={frameSize}樣本");
if (pcmData.Length != expectedBytes)
{
//System.Console.WriteLine($"調(diào)整PCM數(shù)據(jù)長度: 從{pcmData.Length}字節(jié)到{expectedBytes}字節(jié)");
// 調(diào)整數(shù)據(jù)長度或填充零
byte[] adjustedData = new byte[expectedBytes];
if (pcmData.Length < expectedBytes)
{
// 數(shù)據(jù)不足,復(fù)制現(xiàn)有數(shù)據(jù)并填充零
Array.Copy(pcmData, adjustedData, pcmData.Length);
//System.Console.WriteLine($"PCM數(shù)據(jù)不足,已填充{expectedBytes - pcmData.Length}字節(jié)的零");
}
else
{
// 數(shù)據(jù)過多,截斷
Array.Copy(pcmData, adjustedData, expectedBytes);
//System.Console.WriteLine($"PCM數(shù)據(jù)過多,已截斷{pcmData.Length - expectedBytes}字節(jié)");
}
pcmData = adjustedData;
}
// 轉(zhuǎn)換為16位短整型數(shù)組
short[] pcmShorts = new short[frameSize * channels];
for (int i = 0; i < pcmShorts.Length && i * 2 + 1 < pcmData.Length; i++)
{
pcmShorts[i] = BitConverter.ToInt16(pcmData, i * 2);
}
// 可選:添加輸入音頻質(zhì)量檢查
//CheckAudioQuality(pcmData, $"編碼輸入PCM,長度={pcmData.Length}字節(jié)");
// OpusSharp編碼 - 使用正確的API
byte[] outputBuffer = new byte[4000]; // Opus最大包大小
int encodedLength = _encoder.Encode(pcmShorts, frameSize, outputBuffer, outputBuffer.Length);
//System.Console.WriteLine($"編碼結(jié)果: 輸出長度={encodedLength}字節(jié)");
if (encodedLength > 0)
{
// 返回實際編碼的數(shù)據(jù)
byte[] result = new byte[encodedLength];
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)
{
// 驗證輸入?yún)?shù)是否符合官方規(guī)格
if (sampleRate != 16000)
{
System.Console.WriteLine($"警告: 采樣率 {sampleRate} 不符合官方規(guī)格 16000Hz");
}
if (channels != 1)
{
System.Console.WriteLine($"警告: 聲道數(shù) {channels} 不符合官方規(guī)格 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}聲道");
}
// 檢查輸入數(shù)據(jù)有效性
if (encodedData == null || encodedData.Length == 0)
{
System.Console.WriteLine("警告: 接收到空的Opus數(shù)據(jù)包");
int frameSize = sampleRate * 60 / 1000; // 60ms幀,符合官方規(guī)格
byte[] silenceData = new byte[frameSize * channels * 2];
return silenceData;
}
try
{
// 計算幀大小 (采樣數(shù),不是字節(jié)數(shù)) - 嚴格按照官方60ms規(guī)格
int frameSize = sampleRate * 60 / 1000; // 對于16kHz = 960樣本
// 為解碼輸出分配緩沖區(qū),確保有足夠空間
// Opus可能解碼出不同長度的幀,所以使用最大可能的幀大小
int maxFrameSize = sampleRate * 120 / 1000; // 最大120ms幀作為安全緩沖
short[] outputBuffer = new short[maxFrameSize * channels];
System.Console.WriteLine($"解碼Opus數(shù)據(jù): 輸入長度={encodedData.Length}字節(jié), 期望幀大小={frameSize}樣本");
// OpusSharp解碼 - 使用正確的API,讓解碼器自動確定幀大小
int decodedSamples = _decoder.Decode(encodedData, encodedData.Length, outputBuffer, maxFrameSize, false);
System.Console.WriteLine($"解碼結(jié)果: 解碼了{decodedSamples}樣本");
if (decodedSamples > 0)
{
// 驗證解碼出的樣本數(shù)是否合理
if (decodedSamples > maxFrameSize)
{
System.Console.WriteLine($"警告: 解碼樣本數(shù)({decodedSamples})超出最大幀大小({maxFrameSize})");
decodedSamples = maxFrameSize;
}
// 轉(zhuǎn)換為字節(jié)數(shù)組 - 確保正確的字節(jié)序
byte[] pcmBytes = new byte[decodedSamples * channels * 2];
for (int i = 0; i < decodedSamples * channels; i++)
{
var bytes = BitConverter.GetBytes(outputBuffer[i]);
pcmBytes[i * 2] = bytes[0]; // 低字節(jié)
pcmBytes[i * 2 + 1] = bytes[1]; // 高字節(jié)
}
// 可選:添加簡單的音頻質(zhì)量檢查
CheckAudioQuality(pcmBytes, $"解碼輸出PCM,長度={pcmBytes.Length}字節(jié)");
return pcmBytes;
}
else
{
System.Console.WriteLine($"解碼失敗: 返回的樣本數(shù)為 {decodedSamples}");
}
// 返回靜音數(shù)據(jù)而不是空數(shù)組,保持音頻流連續(xù)性
int silenceFrameSize = frameSize * channels * 2;
byte[] silenceData = new byte[silenceFrameSize];
System.Console.WriteLine($"返回靜音數(shù)據(jù): {silenceFrameSize}字節(jié)");
return silenceData;
}
catch (Exception ex)
{
System.Console.WriteLine($"OpusSharp解碼失敗: {ex.Message}");
System.Console.WriteLine($"堆棧跟蹤: {ex.StackTrace}");
// 返回靜音數(shù)據(jù)而不是空數(shù)組,保持音頻流連續(xù)性
int frameSize = sampleRate * 60 / 1000; // 60ms幀
byte[] silenceData = new byte[frameSize * channels * 2];
return silenceData;
}
}
}
/// <summary>
/// 簡單的音頻質(zhì)量檢查,幫助診斷音頻問題
/// </summary>
private void CheckAudioQuality(byte[] pcmData, string context)
{
if (pcmData.Length < 4) return;
// 轉(zhuǎn)換為16位樣本進行分析
var samples = new short[pcmData.Length / 2];
Buffer.BlockCopy(pcmData, 0, samples, 0, pcmData.Length);
// 計算音頻統(tǒng)計信息
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($"音頻質(zhì)量警告 ({context}): {string.Join(", ", issues)}");
//System.Console.WriteLine($" 統(tǒng)計: 樣本數(shù)={samples.Length}, RMS={rms:F1}, 范圍=[{min}, {max}], 零值比例={zeroPercent:F1}%");
}
else
{
//System.Console.WriteLine($"音頻質(zhì)量正常 ({context}): RMS={rms:F1}, 范圍=[{min}, {max}]");
}
}
public void Dispose()
{
lock (_lock)
{
_encoder?.Dispose();
_decoder?.Dispose();
}
}
}
3. SoundFlow音頻框架
跨平臺音頻播放框架:
提供統(tǒng)一的音頻播放接口,屏蔽平臺差異。
錄音初始化代碼:
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 優(yōu)化的音頻分發(fā)器
private bool _isRecording = false;
private bool _isDisposed = false;
private int _sampleRate = 16000;
private int _channels = 1;
private readonly ILogger<SoundFlowAudioRecorder>? _logger;
// 設(shè)備配置 - 優(yōu)化為低延遲錄音
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, // 允許自動采樣率轉(zhuǎn)換
NoDefaultQualitySRC = false, // 允許高質(zhì)量重采樣
NoAutoStreamRouting = false,
NoHardwareOffloading = false
}
};
// 參考 py-xiaozhi 的事件系統(tǒng)
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>
/// 在構(gòu)造函數(shù)中初始化音頻引擎和基礎(chǔ)組件
/// </summary>
private void InitializeAudioEngine()
{
try
{
// 在構(gòu)造時就初始化引擎
_engine = new MiniAudioEngine();
// 顯示可用的錄音設(shè)備(調(diào)試模式)
if (_logger != null && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("SoundFlow錄音引擎初始化完成");
_logger.LogDebug("可用SoundFlow錄音設(shè)備:");
for (int i = 0; i < _engine.CaptureDevices.Length; i++)
{
var device = _engine.CaptureDevices[i];
var marker = device.IsDefault ? " (默認)" : "";
_logger.LogDebug(" [{Index}] {Name}{Marker}", i, device.Name, marker);
}
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "初始化SoundFlow錄音引擎失敗");
throw;
}
}
播放器初始化代碼:
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;
// 設(shè)備配置 - 優(yōu)化為更低延遲播放,減少斷斷續(xù)續(xù)
private static readonly MiniAudioDeviceConfig DeviceConfig = new()
{
PeriodSizeInFrames = 480, // 30ms @ 16kHz = 480 samples (減少到30ms提高響應(yīng)性)
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, // 專業(yè)音頻模式,降低延遲
NoAutoConvertSRC = false, // 允許自動采樣率轉(zhuǎn)換
NoDefaultQualitySRC = false, // 允許高質(zhì)量重采樣
NoAutoStreamRouting = false,
NoHardwareOffloading = false
}
};
public event EventHandler? PlaybackStopped;
public bool IsPlaying => _isPlaying;
public SoundFlowAudioPlayer(ILogger<SoundFlowAudioPlayer>? logger = null)
{
_logger = logger;
// 創(chuàng)建無界通道用于音頻數(shù)據(jù)緩沖,避免阻塞問題
var options = new UnboundedChannelOptions
{
SingleReader = true, // 只有播放任務(wù)讀取
SingleWriter = false, // 多個來源可能寫入音頻數(shù)據(jù)
AllowSynchronousContinuations = false // 避免死鎖
};
InitializeAudioEngine();
// 以默認參數(shù)預(yù)初始化播放設(shè)備與播放器,便于后續(xù)快速切換/播放
try
{
InitializePlaybackDevice(_sampleRate, _channels);
}
catch (Exception ex)
{
// 預(yù)初始化失敗不致命,延遲到首次播放再初始化
_logger?.LogWarning(ex, "SoundFlow預(yù)初始化失敗,將在首次播放時重試");
}
}
/// <summary>
/// 在構(gòu)造函數(shù)中初始化音頻引擎和基礎(chǔ)組件
/// </summary>
private void InitializeAudioEngine()
{
try
{
// 在構(gòu)造時就初始化引擎
_engine = new MiniAudioEngine();
// 顯示可用的播放設(shè)備(調(diào)試模式)
if (_logger != null && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("SoundFlow播放引擎初始化完成");
_logger.LogDebug("可用SoundFlow播放設(shè)備:");
for (int i = 0; i < _engine.PlaybackDevices.Length; i++)
{
var device = _engine.PlaybackDevices[i];
var status = device.IsDefault ? " (默認)" : "";
_logger.LogDebug(" [{Index}] {Name}{Status}", i, device.Name, status);
}
}
if (_engine.PlaybackDevices.Length == 0)
{
throw new InvalidOperationException("未找到SoundFlow音頻播放設(shè)備");
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "初始化SoundFlow播放引擎失敗");
throw;
}
}
/// <summary>
/// 初始化播放設(shè)備(僅在參數(shù)變化時調(diào)用)
/// </summary>
private void InitializePlaybackDevice(int sampleRate, int channels)
{
if (!ValidateAudioParameters(sampleRate, channels))
{
throw new ArgumentException("Invalid audio parameters");
}
// 如果參數(shù)相同且設(shè)備已初始化,直接返回
if (_playbackDevice != null && _sampleRate == sampleRate && _channels == channels)
{
return;
}
// 清理現(xiàn)有設(shè)備
if (_playbackDevice != null)
{
try
{
_playbackDevice.Stop();
_playbackDevice = null;
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "清理舊播放設(shè)備時出錯");
}
}
_sampleRate = sampleRate;
_channels = channels;
try
{
// 引擎已在構(gòu)造函數(shù)中初始化
if (_engine == null)
{
throw new InvalidOperationException("SoundFlow引擎未正確初始化");
}
// 修復(fù):統(tǒng)一使用F32格式,與QueueDataProvider保持一致
var format = new AudioFormat
{
SampleRate = sampleRate,
Channels = channels,
Format = SampleFormat.F32 // 改為F32,與播放器格式一致
};
_playbackDevice = _engine.InitializePlaybackDevice(null, format, DeviceConfig);
_logger?.LogDebug("已選擇SoundFlow播放設(shè)備: {DeviceName}", _playbackDevice.Info?.Name ?? "默認設(shè)備");
_logger?.LogDebug("播放設(shè)備格式: {Format}, {Channels}ch, {SampleRate}Hz",
_playbackDevice.Format.Format, _playbackDevice.Format.Channels, _playbackDevice.Format.SampleRate);
_logger?.LogInformation("SoundFlow音頻播放器設(shè)備初始化成功: {SampleRate}Hz, {Channels}聲道",
sampleRate, channels);
}
catch (Exception ex)
{
throw new Exception($"初始化SoundFlow音頻播放設(shè)備失敗: {ex.Message}", ex);
}
}
/// <summary>
/// 初始化SoundPlayer與QueueDataProvider(僅在參數(shù)變化時調(diào)用)
/// </summary>
private async Task InitializePlayer(int sampleRate, int channels)
{
if (_engine == null || _playbackDevice == null)
{
throw new InvalidOperationException("SoundFlow引擎或播放設(shè)備未初始化");
}
_sampleRate = sampleRate;
_channels = channels;
try
{
// 創(chuàng)建音頻格式 - 匹配測試項目的要求
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();
// 創(chuàng)建QueueDataProvider - 專為流式數(shù)據(jù)設(shè)計
_dataProvider = new QueueDataProvider(format);
_dataProvider.EndOfStreamReached += (s, e) =>
{
_logger?.LogDebug("SoundFlow數(shù)據(jù)提供者已到達流末尾");
PlaybackStopped?.Invoke(this, EventArgs.Empty);
};
// 創(chuàng)建播放器
_soundPlayer = new SoundPlayer(_engine, format, _dataProvider);
// 添加到播放設(shè)備的混音器
_playbackDevice.MasterMixer.AddComponent(_soundPlayer);
_logger?.LogDebug("SoundFlow播放器初始化完成: {SampleRate}Hz, {Channels}ch", sampleRate, channels);
await Task.CompletedTask;
}
catch (Exception ex)
{
_logger?.LogError(ex, "SoundFlow播放器初始化錯誤");
throw;
}
}
4. 關(guān)鍵詞喚醒
支持兩種喚醒詞模型:
- xiaodian(小電):"你好小電"
- cortana(小娜):"你好小娜"
基于音頻流實時檢測,CPU占用低,響應(yīng)速度快。
關(guān)鍵詞喚醒核心邏輯:
/// <summary>
/// 初始化語音配置(離線模式,無需訂閱密鑰)
/// </summary>
private void InitializeSpeechConfig()
{
try
{
// 創(chuàng)建離線語音配置
// 對于關(guān)鍵詞檢測,可以使用空的配置,因為我們使用本地.table文件
_speechConfig = SpeechConfig.FromSubscription("dummy", "dummy");
// 設(shè)置為離線模式
_speechConfig.SetProperty("SPEECH-UseOfflineRecognition", "true");
_logger?.LogInformation("語音配置初始化成功(離線模式)");
}
catch (Exception ex)
{
_logger?.LogError(ex, "初始化語音配置失敗");
_isEnabled = false;
}
}
/// <summary>
/// 啟動關(guān)鍵詞檢測(對應(yīng)py-xiaozhi的start方法)
/// </summary>
public async Task<bool> StartAsync(IAudioRecorder? audioRecorder = null)
{
if (!_isEnabled)
{
_logger?.LogWarning("關(guān)鍵詞檢測功能未啟用");
return false;
}
if (_isRunning)
{
_logger?.LogWarning("關(guān)鍵詞檢測已在運行");
return true;
}
try
{
await _semaphore.WaitAsync();
_cancellationTokenSource = new CancellationTokenSource();
// 設(shè)置音頻源(對應(yīng)py-xiaozhi的多種啟動模式)
if (audioRecorder != null)
{
_audioRecorder = audioRecorder;
_useExternalAudioSource = true;
_logger?.LogInformation("使用外部音頻源啟動關(guān)鍵詞檢測");
}
else
{
_useExternalAudioSource = false;
_logger?.LogInformation("使用獨立音頻模式啟動關(guān)鍵詞檢測");
}
// 加載關(guān)鍵詞模型
if (!await LoadKeywordModelsAsync())
{
_logger?.LogError("加載關(guān)鍵詞模型失敗");
return false;
}
// 配置音頻輸入 - 使用共享音頻流管理器
var audioConfig = await ConfigureSharedAudioInput();
if (audioConfig == null)
{
_logger?.LogError("配置音頻輸入失敗");
return false;
}
// 創(chuàng)建關(guān)鍵詞識別器 - 確保每次啟動都是全新實例
_keywordRecognizer = new KeywordRecognizer(audioConfig);
// 訂閱事件
SubscribeToRecognizerEvents();
// 在后臺任務(wù)中啟動關(guān)鍵詞識別,避免阻塞主流程
_ = Task.Run(async () =>
{
try
{
if (_keywordModel != null && _keywordRecognizer != null)
{
await _keywordRecognizer.RecognizeOnceAsync(_keywordModel);
_logger?.LogInformation("關(guān)鍵詞識別已啟動(后臺任務(wù))");
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "關(guān)鍵詞識別后臺任務(wù)異常");
OnErrorOccurred($"關(guān)鍵詞識別異常: {ex.Message}");
}
});
_isRunning = true;
_isPaused = false;
_logger?.LogInformation("關(guān)鍵詞檢測啟動成功");
return true;
}
catch (Exception ex)
{
_logger?.LogError(ex, "啟動關(guān)鍵詞檢測失敗");
return false;
}
finally
{
_semaphore.Release();
}
}
常見問題排查
樹莓派運行WebAPI服務(wù)初始化語音設(shè)備失敗
- 確認usb聲卡已經(jīng)是默認設(shè)備

- 如何禁用默認的聲卡
通過下面的指令編輯配置文件
sudo nano /boot/firmware/config.txt
注釋掉圖片上的代碼就可以關(guān)閉

連接失敗
- 確認網(wǎng)絡(luò)連接正常
- 查看防火墻是否阻止了連接
音頻問題
- 確認麥克風權(quán)限已授予
- 檢查音頻設(shè)備是否正常工作
- 查看日志中是否有音頻相關(guān)錯誤
Android權(quán)限問題
在AndroidManifest.xml中確認權(quán)限聲明:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
總結(jié)
通過這個項目,我想展示.NET在跨平臺開發(fā)方面的強大能力。一套核心代碼,可以運行在Windows、Android、Linux等多個平臺上。
大多數(shù)的代碼是我指導(dǎo)Github Copilot生成的,要是我個人獨自實現(xiàn)的話,應(yīng)該會花費更多的時間,我個人感覺AI編程目前對于個人開發(fā)者來說確實能夠幫助很多的,如果生成的質(zhì)量不好,我們需要調(diào)整提示詞和更換更厲害的模型,請不要放棄使用。
項目代碼開源在GitHub上,文檔也比較完善。如果你對.NET跨平臺開發(fā)或AI語音助手感興趣,可以下載下來研究研究。遇到問題歡迎提Issue或者在評論區(qū)討論。
目前項目的文檔有使用AI進行一些編寫,但是我沒有進行仔細的校驗,請大家以代碼實現(xiàn)為準。
希望這篇文章能幫助大家快速上手這個項目。后續(xù)我還會繼續(xù)更新相關(guān)的技術(shù)細節(jié)和開發(fā)經(jīng)驗,歡迎持續(xù)關(guān)注!
參考資源
- GitHub項目地址:https://github.com/maker-community/Verdure.Assistant
- 項目文檔站點:https://verdure-assistant.verdure-hiro.cn/zh/
- VerdiBot機器人項目:https://github.com/maker-community/VerdiBot
- 創(chuàng)客社區(qū):https://github.com/maker-community
- .NET官方文檔:https://docs.microsoft.com/zh-cn/dotnet/
- WinUI 3文檔:https://learn.microsoft.com/windows/apps/winui/winui3/
- .NET MAUI文檔:https://learn.microsoft.com/dotnet/maui/
- xiaozhi-esp32 ESP32 參考實現(xiàn):https://github.com/78/xiaozhi-esp32
- py-xiaozhi Python 參考實現(xiàn):https://github.com/huangjunsen0406/py-xiaozhi
- xiaozhi-sharp:https://github.com/GreenShadeZhang/xiaozhi-sharp
- SoundFlow:https://github.com/LSXPrime/SoundFlow
- 本人B站地址::https://space.bilibili.com/25228512
本文首發(fā)于個人技術(shù)博客,轉(zhuǎn)載請注明出處。如果對.NET跨平臺開發(fā)和IoT感興趣,歡迎關(guān)注我的博客獲取更多技術(shù)分享!

浙公網(wǎng)安備 33010602011771號