前言

前面几篇文章已经把机器人硬件控制部分的开发讲得差不多了,包括屏幕控制、舵机驱动、语音交互等功能。但是之前的外形太过简单,可动角度不够多,所以我就新改进了一个版本,叫VerdiBot(阿荫),详细视频介绍地址请点击链接

ESP32社区最火的AI对话机器人非小智AI莫属了,所以为了让自己做的机器人对话部分也足够的生动我就重新实现了一个.NET版本的小智客户端,打算后期集成更多的功能,并整理成了一个完整的开源项目——Verdure Assistant(绿荫助手),这是一个基于.NET 9.0的多平台AI语音助手,支持Windows桌面、Android移动端、命令行以及Web API等多种使用方式。

这篇文章主要是给大家讲讲这个对话机器人项目的一些代码,方便想尝试的小伙伴快速上手体验。项目代码已经开源了,大家可以自己研究,遇到问题也欢迎提Issue讨论。

GitHub项目地址https://github.com/maker-community/Verdure.Assistant

问题解答

Q: 之前为什么特意做树莓派wifi配网的功能?

A: 之前的博客有网友说我浪费生命开发wifi配网功能,我在评论区也有讲过原因,现在我在这里再讲一遍,因为有时候我们拿着设备到新环境的时候,并不能时刻有可用的显示器和鼠标键盘,但是又需要联网,这时就可以使用wifi配网了。然后ssh连接到设备上就可以像服务器一样控制了。

Q: 支持哪些AI服务?

A: 目前主要对接的是小智AI服务,后续计划支持更多AI服务的接入,包括OpenAI等。项目采用了抽象设计,扩展起来比较方便。

Q: 项目使用什么技术栈?

A: 核心使用.NET 9.0,跨平台UI用.NET MAUI,Windows桌面使用的WinUI 3。网络音频编解码用的OpusSharp库,音频录制播放使用的最近社区刚有人开源的的SoundFlow库,这个库功能完善,使用方便,并且内置了多种音频格式解码的播放,所以我用它替换了之前的PortAudioSharp2,网络通信基于WebSocket和MQTT(未测试)。详细的技术点在GitHub的README里都有说明。

Q: 为什么要重新实现这个项目?

A: 目前小智AI机器人有免费的服务端可以使用,而且整个架构都很优雅,对比我之前的实现优点很多,所以重新实现一个客户端对于用户体验有很大的帮助,并且协议是公开的,以后如果想自己拓展实现服务端也是很轻松的。

项目整体架构

目录结构

项目采用清晰的分层架构,便于理解和扩展:

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/ # 构建脚本

GitHub项目地址https://github.com/maker-community/Verdure.Assistant

核心功能模块

  • 语音交互模块:使用微软的语音认知服务的关键词唤醒,加载关键词唤醒模型文件不需要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

  • 网络通信模块:WebSocket实时通信、MQTT物联网协议

    src/Verdure.Assistant.Core/Services/Protocols/WebSocketClient.cs

  • 状态管理模块:设备状态机、会话状态控制

    src/Verdure.Assistant.Core/Services/StateMachine/ConversationStateMachine.cs

    src/Verdure.Assistant.Core/Services/StateMachine/ConversationStateMachineContext.cs

  • 音乐播放模块:集成酷狗/酷我API、在线播放和缓存

    src/Verdure.Assistant.Core/Services/KuwoMusicService

应用截图与演示

️ WinUI 桌面应用

演示视频:点击在新标签页播放

现代化的 Windows 桌面应用界面,支持语音交互和实时状态显示


MAUI 移动应用(Android)

演示视频:点击在新标签页播放

基于 .NET MAUI 的 Android 移动应用,支持后台语音处理和音乐播放


MAUI 安卓手表应用(Android Watch)

演示视频:点击在新标签页播放

基于 .NET MAUI 的安卓手表应用,适配圆形/方形表盘,支持语音助手核心功能


Web API 服务

演示视频:点击在新标签页播放

适合树莓派机器人和普通的测试使用

快速开始

环境准备

基础要求

  • .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中直接设置为启动项目运行。

使用流程

  1. 启动应用后,界面会显示连接状态
  2. 如果没有在小智后台绑定,会提示进行绑定
  3. 绑定完成说出你好小电开启对话
  4. 说再见会再次进入等待状态

功能特性

  • 自动模式:自动持续监听,无需重复唤醒
  • 实时状态显示:连接状态、语音识别状态可视化
  • 音乐控制:搜索、播放、暂停音乐
  • 主题切换:支持深色/浅色主题

2. Android移动版(MAUI)

运行方式

使用Visual Studio打开解决方案,选择Android设备或模拟器:

使用流程

  1. 安装APK到Android设备
  2. 授予录音通知权限
  3. 使用唤醒词开启对话

3. 命令行版(Console)

运行方式

cd src/Verdure.Assistant.Console
dotnet restore
dotnet run

使用场景

  • 服务器端部署(Linux/Windows Server)
  • 开发调试和测试
  • 查看详细日志输出
  • 自动化脚本集成

4. Web API服务(树莓派/服务器)

运行方式

cd src/Verdure.Assistant.Api
dotnet restore
dotnet run

主要API端点

音乐相关:

# 搜索音乐
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

树莓派部署

适合部署在树莓派等嵌入式设备上,配合VerdiBot硬件机器人使用。详细部署步骤参考项目中的API文档。

核心技术详解

1. 会话状态机

项目使用状态机管理设备状态,主要状态包括:

  • IDLE(空闲):等待唤醒
  • LISTENING(监听):正在录音
  • SPEAKING(说话):播放回复

状态转换逻辑清晰,避免混乱的条件判断。

核心代码如下:

请求状态变更代码:

/// <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;
}
}

状态处理代码:

 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;
}

2. Opus编解码

使用Opus编解码器进行音频压缩,特点:

  • 低延迟:适合实时语音通信
  • 高质量:保证语音清晰度
  • 带宽节省:有效降低网络传输压力

项目中封装了OpusCodec类,简化了编解码操作。

完整代码如下:

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[expectedBytes];
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[frameSize * channels];
for (int i = 0; i < pcmShorts.Length && i * 2 + 1 < pcmData.Length; i++)
{
pcmShorts[i] = BitConverter.ToInt16(pcmData, i * 2);
} // 可选:添加输入音频质量检查
//CheckAudioQuality(pcmData, $"编码输入PCM,长度={pcmData.Length}字节"); // OpusSharp编码 - 使用正确的API
byte[] outputBuffer = new byte[4000]; // Opus最大包大小
int encodedLength = _encoder.Encode(pcmShorts, frameSize, outputBuffer, outputBuffer.Length); //System.Console.WriteLine($"编码结果: 输出长度={encodedLength}字节"); if (encodedLength > 0)
{
// 返回实际编码的数据
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)
{
// 验证输入参数是否符合官方规格
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[frameSize * channels * 2];
return silenceData;
} try
{
// 计算帧大小 (采样数,不是字节数) - 严格按照官方60ms规格
int frameSize = sampleRate * 60 / 1000; // 对于16kHz = 960样本 // 为解码输出分配缓冲区,确保有足够空间
// Opus可能解码出不同长度的帧,所以使用最大可能的帧大小
int maxFrameSize = sampleRate * 120 / 1000; // 最大120ms帧作为安全缓冲
short[] outputBuffer = new short[maxFrameSize * channels]; 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[decodedSamples * channels * 2];
for (int i = 0; i < decodedSamples * channels; i++)
{
var bytes = BitConverter.GetBytes(outputBuffer[i]);
pcmBytes[i * 2] = bytes[0]; // 低字节
pcmBytes[i * 2 + 1] = bytes[1]; // 高字节
} // 可选:添加简单的音频质量检查
CheckAudioQuality(pcmBytes, $"解码输出PCM,长度={pcmBytes.Length}字节"); return pcmBytes;
}
else
{
System.Console.WriteLine($"解码失败: 返回的样本数为 {decodedSamples}");
} // 返回静音数据而不是空数组,保持音频流连续性
int silenceFrameSize = frameSize * channels * 2;
byte[] silenceData = new byte[silenceFrameSize];
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[frameSize * channels * 2];
return silenceData;
}
}
} /// <summary>
/// 简单的音频质量检查,帮助诊断音频问题
/// </summary>
private void CheckAudioQuality(byte[] pcmData, string context)
{
if (pcmData.Length < 4) return; // 转换为16位样本进行分析
var samples = new short[pcmData.Length / 2];
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();
}
}
}

3. SoundFlow音频框架

跨平台音频播放框架:

提供统一的音频播放接口,屏蔽平台差异。

录音初始化代码:

    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[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;
// 设备配置 - 优化为更低延迟播放,减少断断续续
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[i];
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;
}
}

4. 关键词唤醒

支持两种唤醒词模型:

  • xiaodian(小电):"你好小电"
  • cortana(小娜):"你好小娜"

基于音频流实时检测,CPU占用低,响应速度快。

关键词唤醒核心逻辑:

    /// <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();
}
}

常见问题排查

树莓派运行WebAPI服务初始化语音设备失败

  1. 确认usb声卡已经是默认设备

  1. 如何禁用默认的声卡

    通过下面的指令编辑配置文件
sudo nano /boot/firmware/config.txt

注释掉图片上的代码就可以关闭

连接失败

  1. 确认网络连接正常
  2. 查看防火墙是否阻止了连接

音频问题

  1. 确认麦克风权限已授予
  2. 检查音频设备是否正常工作
  3. 查看日志中是否有音频相关错误

Android权限问题

AndroidManifest.xml中确认权限声明:

<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

总结

通过这个项目,我想展示.NET在跨平台开发方面的强大能力。一套核心代码,可以运行在Windows、Android、Linux等多个平台上。

大多数的代码是我指导Github Copilot生成的,要是我个人独自实现的话,应该会花费更多的时间,我个人感觉AI编程目前对于个人开发者来说确实能够帮助很多的,如果生成的质量不好,我们需要调整提示词和更换更厉害的模型,请不要放弃使用。

项目代码开源在GitHub上,文档也比较完善。如果你对.NET跨平台开发或AI语音助手感兴趣,可以下载下来研究研究。遇到问题欢迎提Issue或者在评论区讨论。

目前项目的文档有使用AI进行一些编写,但是我没有进行仔细的校验,请大家以代码实现为准。

希望这篇文章能帮助大家快速上手这个项目。后续我还会继续更新相关的技术细节和开发经验,欢迎持续关注!

参考资源


本文首发于个人技术博客,转载请注明出处。如果对.NET跨平台开发和IoT感兴趣,欢迎关注我的博客获取更多技术分享!

用纯.NET开发并制作一个智能桌面机器人(六):使用.NET开发一个跨平台功能完善的小智AI客户端的更多相关文章

  1. 【Abode Air程序开发】移动设备、桌面和浏览器应用程序开发的差异

    移动设备.桌面和浏览器应用程序开发的差异 在移动设备应用程序中使用 Spark 和 MX 组件的限制 移动设备应用程序在性能方面的注意事项 浏览器  将应用程序部署为 SWF 文件,以用于在浏览器中运 ...

  2. NEO从入门到开窗(1) - 一个智能合约的诞生

    一.啰嗦两句 最近一直都在研究区块链,BitCoin,Etherenum, Hyper Ledger Fabric还有今天的主角小蚂蚁,当然出名以后改了一个艺名叫NEO.区块链大部分都是用Golang ...

  3. AI中台——智能聊天机器人平台的架构与应用(分享实录)

    内容来源:宜信技术学院第3期技术沙龙-线上直播|AI中台——智能聊天机器人平台 主讲人:宜信科技中心AI中台团队负责人王东 导读:随着“中台”战略的提出,目前宜信中台建设在思想理念及架构设计上都已经取 ...

  4. 基于Android的小巫新闻客户端开发系列教程

    <ignore_js_op> 141224c6n6x7wmu1aacap7.jpg (27.51 KB, 下载次数: 0) 下载附件  保存到相册 23 秒前 上传   <ignor ...

  5. 1、利用蓝牙定位及姿态识别实现一个智能篮球场套件(一)——用重写CC2541透传模块做成智能手环

    一.预言 要实现一个智能篮球场套件,需要设计一个佩戴在篮球运动员手臂上的可以检测投篮.记步的手环,以及一套可以根据RSSI定位运动员的蓝牙定位装置.下面是大致需要的步骤: 首先,需要用CC2541透传 ...

  6. 基于C/S架构的3D对战网络游戏C++框架 _05搭建系统开发环境与Boost智能指针、内存池初步了解

    本系列博客主要是以对战游戏为背景介绍3D对战网络游戏常用的开发技术以及C++高级编程技巧,有了这些知识,就可以开发出中小型游戏项目或3D工业仿真项目. 笔者将分为以下三个部分向大家介绍(每日更新): ...

  7. 2、利用蓝牙定位及姿态识别实现一个智能篮球场套件(二)——CC2540/CC2541基于广播的RSSI获得

    CC2541一拖多例程中RSSI获得是通过一个事件回调函数实现的,前提是需要连接上蓝牙设备. 这个对于多点定位来说是不可行的,由于主机搜索蓝牙设备过程中也能获得当前蓝牙设备的RSSI等信息,因此可基于 ...

  8. 2016开发一个app需要多少钱?app开发需要哪些成本-app开发问题汇总-广州达到信息

    作为一个APP开发从业者,被外行的朋友们问及最多的问题是,"做一个网站需要多少钱?"或者"开发一个APP需要多少钱?".作为开发过完整网站项目和手机APP的人, ...

  9. C# Windows Phone 8 WP8 高级开发,制作不循环 Pivot ,图片(Gallery)导览不求人! 内附图文教学!!

    原文:C# Windows Phone 8 WP8 高级开发,制作不循环 Pivot ,图片(Gallery)导览不求人! 内附图文教学!! 一般我们在开发Winodws Phone APP 的时候往 ...

  10. iOS开发之--制作属于自己的frameWork

    开发的时候,有时候,我们会遇到协同开发,在协同开发的时候,每个开发者都会创建自己的工具类,还有就是当一个项目需要嵌套到另一个项目里面,这些时候,如果能把所需的部分打包成framework,会方便很多, ...

随机推荐

  1. vmtools 安装 灰色

    简介 vmtools 的快照功能相当给力有很多的测试可以放在虚拟机中执行而不用当心搞坏系统 vmtools 安装后屏幕的大小能自适应,带来一种比较新的体验.值得一试!!! 基本的安装步骤可以参考 他人 ...

  2. bluz-5.47 蓝牙

    为什么android手机的蓝牙比如说 googplay 比较有名的nRf Connect , BLE scanner ,在安卓手机上都不能发现,树莓派开启的服务. 因为,可能是服务走了 GATT 服务 ...

  3. 进阶篇:3.4.3)DFM钻削件设计指南

    本章目的:设计出符合钻削工艺的零件 1.钻削件设计准则 1.1 转削成本太高,可以考虑一级工艺直接成形 钻削加工孔的方式的优点在于可以提供精密的尺寸,但是相对其他孔加工方式,钻削加工成本较高,因此需考 ...

  4. 进阶篇:3.1.6)DFM塑胶-模压件(橡胶件)设计

    本章目的:设计出适合模压工艺的零件,特别是橡胶件. 1.模压Compression Molding工艺概念介绍 模压成型又称压制成型或压缩成型,是先将粉状,粒状或纤维状的塑料放入成型温度下的模具型腔中 ...

  5. 腾讯云CentOS 8安装及配置 MySQL

    腾讯云CentOS 8安装及配置 MySQL 自己安装了两天没成功,直呼腾讯云牛逼 手动搭建 LNMP 环境(CentOS 8) 执行以下命令,安装 MySQL. dnf -y install @my ...

  6. 游戏技术博客推荐 Red Blob Games

    https://www.redblobgames.com/ 一名专注于游戏中算法实现的大佬

  7. luogu P1048 [NOIP2005 普及组] 采药

    [NOIP2005 普及组] 采药 题目描述 辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师.为此,他想拜附近最有威望的医师为师.医师为了判断他的资质,给他出了一个难题.医师把他带到一个到处 ...

  8. ClickHouse 运维相关部分命令记录

    利用clickhouse-client和linux管道命令结合,迁移数据.这个办法速度不是特别快,一下午大概迁移40亿数据的样子 clickhouse-client --host 127.0.0.1 ...

  9. Golang 基础之数据类型梳理

    大家好,将梳理出的 Go语言数据类型内容,分享给大家. 请多多指教,谢谢. 类型汇总 Go 语言中,主要分为值类型主要分为三部分: 整型.浮点型和其他类型. // 整型 int int8 int16 ...

  10. Django模型迁移指南:从命令用法到最佳实践

    一.迁移的工作原理 Django 迁移系统本质上是一套数据库版本控制系统,它通过以下三个核心环节实现模型与数据库的同步 生成迁移文件:当模型发生变更时,makemigrations 命令会生成记录变更 ...