From 3702252e001d10b2da1e69bc1ba2103611e1fc70 Mon Sep 17 00:00:00 2001 From: zpc Date: Fri, 28 Mar 2025 14:48:09 +0800 Subject: [PATCH] tijiao11 --- .../Services/PhoneBoothService.cs | 18 +- ShengShengBuXi.ConsoleApp/appsettings.json | 3 +- ShengShengBuXi.ConsoleApp/config.json | 2 +- ShengShengBuXi/Hubs/AudioHub.cs | 17 +- ShengShengBuXi/Models/PhoneBoothConfig.cs | 5 + .../Services/AudioProcessingService.cs | 994 ++++++++++++------ .../Services/IAudioProcessingService.cs | 33 +- .../Services/SpeechToTextService.cs | 2 +- ShengShengBuXi/config.json | 1 + 9 files changed, 727 insertions(+), 348 deletions(-) diff --git a/ShengShengBuXi.ConsoleApp/Services/PhoneBoothService.cs b/ShengShengBuXi.ConsoleApp/Services/PhoneBoothService.cs index e2a6c19..4674769 100644 --- a/ShengShengBuXi.ConsoleApp/Services/PhoneBoothService.cs +++ b/ShengShengBuXi.ConsoleApp/Services/PhoneBoothService.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Runtime.InteropServices; using ShengShengBuXi.ConsoleApp.Models; using System.Linq; +using System.Transactions; namespace ShengShengBuXi.ConsoleApp.Services; @@ -867,6 +868,9 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable } } + public static DateTime _lasHasoudDateTime = DateTime.Now; + public static bool _isSpeaking = false; + /// /// 检查是否有声音 /// @@ -894,9 +898,19 @@ public class PhoneBoothService : IPhoneBoothService, IDisposable } } } - + bool isSpeaking = maxVolume > _config.Recording.SilenceThreshold; + if (isSpeaking && !_isSpeaking) + { + _isSpeaking = true; + } + if (DateTime.Now.Subtract(_lasHasoudDateTime).TotalSeconds > 1) + { + Console.WriteLine($"当前音量:{maxVolume},设置的音量灵敏度:{_config.Recording.SilenceThreshold},是否已监测到在说话中:{_isSpeaking}"); + _lasHasoudDateTime = DateTime.Now; + _isSpeaking = false; + } // 如果最大音量超过阈值,则认为有声音 - return maxVolume > _config.Recording.SilenceThreshold; + return isSpeaking; } /// diff --git a/ShengShengBuXi.ConsoleApp/appsettings.json b/ShengShengBuXi.ConsoleApp/appsettings.json index a3f1396..e5986d1 100644 --- a/ShengShengBuXi.ConsoleApp/appsettings.json +++ b/ShengShengBuXi.ConsoleApp/appsettings.json @@ -1,5 +1,6 @@ { - "SignalRHubUrl": "http://115.159.44.16/audiohub", + + "SignalRHubUrl": "http://localhost:81/audiohub", "ConfigBackupPath": "config.json", "AutoConnectToServer": true } \ No newline at end of file diff --git a/ShengShengBuXi.ConsoleApp/config.json b/ShengShengBuXi.ConsoleApp/config.json index dc64b8f..ca0f75c 100644 --- a/ShengShengBuXi.ConsoleApp/config.json +++ b/ShengShengBuXi.ConsoleApp/config.json @@ -19,7 +19,7 @@ "RecordingDeviceNumber": 0, "SampleRate": 16000, "Channels": 1, - "BufferMilliseconds": 100, + "BufferMilliseconds": 50, "SilenceThreshold": 0.15, "SilenceTimeoutSeconds": 30, "AllowUserHangup": true, diff --git a/ShengShengBuXi/Hubs/AudioHub.cs b/ShengShengBuXi/Hubs/AudioHub.cs index 8f63618..61457e7 100644 --- a/ShengShengBuXi/Hubs/AudioHub.cs +++ b/ShengShengBuXi/Hubs/AudioHub.cs @@ -83,7 +83,7 @@ namespace ShengShengBuXi.Hubs /// 预设句子列表 /// private static List _presetSentences = new List(); - + // 配置信息 private static string _displayConfig = "{}"; private static object _displayConfigLock = new object(); @@ -480,9 +480,18 @@ namespace ShengShengBuXi.Hubs { try { - _logger.LogDebug($"转发音频数据到{monitoringClients.Count}个监听客户端,数据长度: {audioData.Length}"); - // 尝试直接发送数据 - await Clients.Clients(monitoringClients).SendAsync("ReceiveAudioData", audioData); + if (_configurationService.CurrentConfig.Network.EnableAudioNoiseReduction) + { + var jiangzao = _audioProcessingService.ApplyNoiseReduction(audioData, config.SampleRate, config.Channels); + _logger.LogDebug($"转发音频数据到{monitoringClients.Count}个监听客户端,数据长度: {audioData.Length},降噪后长度:{jiangzao.Length}"); + // 尝试直接发送数据 + await Clients.Clients(monitoringClients).SendAsync("ReceiveAudioData", jiangzao); + } + else + { + // 尝试直接发送数据 + await Clients.Clients(monitoringClients).SendAsync("ReceiveAudioData", audioData); + } } catch (Exception ex) { diff --git a/ShengShengBuXi/Models/PhoneBoothConfig.cs b/ShengShengBuXi/Models/PhoneBoothConfig.cs index 463b444..8768b2d 100644 --- a/ShengShengBuXi/Models/PhoneBoothConfig.cs +++ b/ShengShengBuXi/Models/PhoneBoothConfig.cs @@ -309,6 +309,11 @@ public class NetworkConfig /// 是否启用实时音频传输 /// public bool EnableAudioStreaming { get; set; } = true; + /// + /// 是否开启音频降噪 + /// + + public bool EnableAudioNoiseReduction { get; set; } = false; /// /// 心跳间隔(秒) diff --git a/ShengShengBuXi/Services/AudioProcessingService.cs b/ShengShengBuXi/Services/AudioProcessingService.cs index 4335f7c..e28653d 100644 --- a/ShengShengBuXi/Services/AudioProcessingService.cs +++ b/ShengShengBuXi/Services/AudioProcessingService.cs @@ -1,6 +1,11 @@ using Microsoft.Extensions.Logging; + +using NAudio.Dsp; +using NAudio.Utils; using NAudio.Wave; + using ShengShengBuXi.Models; + using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -9,392 +14,721 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -namespace ShengShengBuXi.Services +namespace ShengShengBuXi.Services; + +/// +/// 音频处理服务实现 +/// +public class AudioProcessingService : IAudioProcessingService { + private readonly ILogger _logger; + private readonly IConfigurationService _configService; + private readonly ConcurrentDictionary _activeRecordings = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _recordingFilePaths = new ConcurrentDictionary(); + private bool _isDisposed; + /// - /// 音频处理服务实现 + /// 当有新的语音转文字结果时触发 /// - public class AudioProcessingService : IAudioProcessingService + public event EventHandler SpeechToTextResultReceived; + + /// + /// 当音频数据被保存为文件时触发 + /// + public event EventHandler AudioSavedToFile; + + /// + /// 初始化音频处理服务 + /// + /// 日志记录器 + /// 配置服务 + public AudioProcessingService(ILogger logger, IConfigurationService configService) { - private readonly ILogger _logger; - private readonly IConfigurationService _configService; - private readonly ConcurrentDictionary _activeRecordings = new ConcurrentDictionary(); - private readonly ConcurrentDictionary _recordingFilePaths = new ConcurrentDictionary(); - private bool _isDisposed; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _configService = configService ?? throw new ArgumentNullException(nameof(configService)); + } - /// - /// 当有新的语音转文字结果时触发 - /// - public event EventHandler SpeechToTextResultReceived; - - /// - /// 当音频数据被保存为文件时触发 - /// - public event EventHandler AudioSavedToFile; - - /// - /// 初始化音频处理服务 - /// - /// 日志记录器 - /// 配置服务 - public AudioProcessingService(ILogger logger, IConfigurationService configService) + /// + /// 初始化音频处理服务 + /// + /// 初始化是否成功 + public bool Initialize() + { + try { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _configService = configService ?? throw new ArgumentNullException(nameof(configService)); + _logger.LogInformation("初始化音频处理服务"); + + // 确保录音文件夹存在 + var recordingsFolder = Path.Combine(Directory.GetCurrentDirectory(), _configService.CurrentConfig.Recording.RecordingsFolder); + if (!Directory.Exists(recordingsFolder)) + { + Directory.CreateDirectory(recordingsFolder); + _logger.LogInformation($"已创建录音文件夹: {recordingsFolder}"); + } + + // 如果配置了自动清理,清理旧录音 + if (_configService.CurrentConfig.Recording.AutoCleanupOldRecordings) + { + CleanupOldRecordings(); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError($"初始化音频处理服务失败: {ex.Message}"); + return false; + } + } + + /// + /// 处理接收到的音频数据 + /// + /// 音频数据 + /// 采样率 + /// 声道数 + /// 客户端ID + /// 取消令牌 + /// 异步任务 + public async Task ProcessAudioDataAsync(byte[] audioData, int sampleRate, int channels, string clientId, CancellationToken token = default) + { + if (audioData == null || audioData.Length == 0) + { + return; } - /// - /// 初始化音频处理服务 - /// - /// 初始化是否成功 - public bool Initialize() + if (string.IsNullOrEmpty(clientId)) { - try - { - _logger.LogInformation("初始化音频处理服务"); - - // 确保录音文件夹存在 - var recordingsFolder = Path.Combine(Directory.GetCurrentDirectory(), _configService.CurrentConfig.Recording.RecordingsFolder); - if (!Directory.Exists(recordingsFolder)) - { - Directory.CreateDirectory(recordingsFolder); - _logger.LogInformation($"已创建录音文件夹: {recordingsFolder}"); - } - - // 如果配置了自动清理,清理旧录音 - if (_configService.CurrentConfig.Recording.AutoCleanupOldRecordings) - { - CleanupOldRecordings(); - } - - return true; - } - catch (Exception ex) - { - _logger.LogError($"初始化音频处理服务失败: {ex.Message}"); - return false; - } + throw new ArgumentNullException(nameof(clientId)); } - /// - /// 处理接收到的音频数据 - /// - /// 音频数据 - /// 采样率 - /// 声道数 - /// 客户端ID - /// 取消令牌 - /// 异步任务 - public async Task ProcessAudioDataAsync(byte[] audioData, int sampleRate, int channels, string clientId, CancellationToken token = default) + // 检查是否有活动的录音 + if (!_activeRecordings.TryGetValue(clientId, out var writer)) { - if (audioData == null || audioData.Length == 0) - { - return; - } - - if (string.IsNullOrEmpty(clientId)) - { - throw new ArgumentNullException(nameof(clientId)); - } - - // 检查是否有活动的录音 - if (!_activeRecordings.TryGetValue(clientId, out var writer)) - { - _logger.LogWarning($"客户端没有活动的录音会话: {clientId}"); - return; - } - - try - { - // 异步写入音频数据 - await Task.Run(() => { writer.Write(audioData, 0, audioData.Length); writer.Flush(); }, token); - } - catch (Exception ex) - { - _logger.LogError($"处理音频数据失败: {ex.Message}"); - } + _logger.LogWarning($"客户端没有活动的录音会话: {clientId}"); + return; } - /// - /// 开始新的音频流处理 - /// - /// 客户端ID - /// 采样率 - /// 声道数 - /// 取消令牌 - /// 异步任务 - public Task StartAudioStreamAsync(string clientId, int sampleRate, int channels, CancellationToken token = default) + try { - if (string.IsNullOrEmpty(clientId)) - { - throw new ArgumentNullException(nameof(clientId)); - } + // 异步写入音频数据 + await Task.Run(() => { writer.Write(audioData, 0, audioData.Length); writer.Flush(); }, token); + } + catch (Exception ex) + { + _logger.LogError($"处理音频数据失败: {ex.Message}"); + } + } - // 如果已经有一个活动的录音,先结束它 - if (_activeRecordings.TryGetValue(clientId, out var existingWriter)) - { - _logger.LogWarning($"客户端已有活动的录音会话,先结束它: {clientId}"); - EndAudioStreamAsync(clientId).Wait(token); - } - - try - { - // 创建录音文件路径 - var config = _configService.CurrentConfig.Recording; - var recordingsFolder = Path.Combine(Directory.GetCurrentDirectory(), config.RecordingsFolder); - if (!Directory.Exists(recordingsFolder)) - { - Directory.CreateDirectory(recordingsFolder); - } - - // 创建文件名 - var fileName = string.Format(config.FileNameFormat, DateTime.Now); - var filePath = Path.Combine(recordingsFolder, fileName); - - // 创建WaveFormat - var waveFormat = new WaveFormat(sampleRate, channels); - - // 创建音频文件写入器 - var writer = new WaveFileWriter(filePath, waveFormat); - - // 添加到活动录音 - if (_activeRecordings.TryAdd(clientId, writer)) - { - _recordingFilePaths[clientId] = filePath; - _logger.LogInformation($"开始音频流处理: {clientId}, 文件: {filePath}"); - } - else - { - _logger.LogError($"无法开始音频流处理,添加到活动录音失败: {clientId}"); - writer.Dispose(); - } - } - catch (Exception ex) - { - _logger.LogError($"开始音频流处理失败: {ex.Message}"); - } - - return Task.CompletedTask; + /// + /// 应用噪声消除 + /// + /// 音频数据 + /// 采样率 + /// 声道数 + /// 噪声门限值 + /// 攻击时间 + /// 释放时间 + /// 高通滤波器截止频率(Hz) + /// 滤波器Q值 + /// + public byte[] ApplyNoiseReduction(byte[] audioData, int sampleRate = 16000, int channels = 1, float noiseThreshold = 0.015f, float attackSeconds = 0.01f, float releaseSeconds = 0.1f, int highPassCutoff = 80, float q = 1.0f) + { + using (var inputStream = new MemoryStream(audioData)) + using (var waveStream = new RawSourceWaveStream(inputStream, new WaveFormat(16000, 16, 1))) + { + var sampleProvider = waveStream.ToSampleProvider(); + + // 改进1:更温和的噪声门参数 + var noiseGate = new ImprovedNoiseGate(sampleProvider) + { + Threshold = 0.015f, // 降低阈值(原0.02) + AttackSeconds = 0.05f, // 延长Attack时间(原0.01) + ReleaseSeconds = 0.3f, // 延长Release时间(原0.1) + HoldSeconds = 0.2f // 新增保持时间 + }; + + // 改进2:更平缓的高通滤波 + var highPassFilter = new BiQuadFilterSampleProvider(noiseGate); + highPassFilter.Filter = BiQuadFilter.HighPassFilter( + sampleProvider.WaveFormat.SampleRate, + 60, // 降低截止频率(原80) + 0.707f); // 使用更平缓的Q值(原1.0) + + // 改进3:添加平滑处理 + var smoothedProvider = new SmoothingSampleProvider(highPassFilter); + + var outputStream = new MemoryStream(); + WaveFileWriter.WriteWavFileToStream(outputStream, smoothedProvider.ToWaveProvider16()); + + return outputStream.ToArray(); + } + } + /// + /// 开始新的音频流处理 + /// + /// 客户端ID + /// 采样率 + /// 声道数 + /// 取消令牌 + /// 异步任务 + public Task StartAudioStreamAsync(string clientId, int sampleRate, int channels, CancellationToken token = default) + { + if (string.IsNullOrEmpty(clientId)) + { + throw new ArgumentNullException(nameof(clientId)); } - /// - /// 结束音频流处理 - /// - /// 客户端ID - /// 取消令牌 - /// 异步任务 - public Task EndAudioStreamAsync(string clientId, CancellationToken token = default) + // 如果已经有一个活动的录音,先结束它 + if (_activeRecordings.TryGetValue(clientId, out var existingWriter)) { - if (string.IsNullOrEmpty(clientId)) - { - throw new ArgumentNullException(nameof(clientId)); - } - - try - { - // 移除并处理现有的录音 - if (_activeRecordings.TryRemove(clientId, out var writer)) - { - _recordingFilePaths.TryGetValue(clientId, out var filePath); - - // 关闭和释放写入器 - writer.Close(); - writer.Dispose(); - - _logger.LogInformation($"结束音频流: {clientId}"); - - // 如果有文件路径,触发事件 - if (!string.IsNullOrEmpty(filePath)) - { - // 触发音频保存到文件事件 - OnAudioSavedToFile(filePath); - } - } - } - catch (Exception ex) - { - _logger.LogError($"结束音频流处理失败: {ex.Message}"); - } - - return Task.CompletedTask; + _logger.LogWarning($"客户端已有活动的录音会话,先结束它: {clientId}"); + EndAudioStreamAsync(clientId).Wait(token); } - /// - /// 获取会话的录音文件路径 - /// - /// 客户端ID - /// 录音文件路径,如果没有则返回null - public string GetRecordingFilePath(string clientId) + try { - if (string.IsNullOrEmpty(clientId)) + // 创建录音文件路径 + var config = _configService.CurrentConfig.Recording; + var recordingsFolder = Path.Combine(Directory.GetCurrentDirectory(), config.RecordingsFolder); + if (!Directory.Exists(recordingsFolder)) { - return null; + Directory.CreateDirectory(recordingsFolder); } - - if (_recordingFilePaths.TryGetValue(clientId, out var filePath)) + + // 创建文件名 + var fileName = string.Format(config.FileNameFormat, DateTime.Now); + var filePath = Path.Combine(recordingsFolder, fileName); + + // 创建WaveFormat + var waveFormat = new WaveFormat(sampleRate, channels); + + // 创建音频文件写入器 + var writer = new WaveFileWriter(filePath, waveFormat); + + // 添加到活动录音 + if (_activeRecordings.TryAdd(clientId, writer)) { - return filePath; + _recordingFilePaths[clientId] = filePath; + _logger.LogInformation($"开始音频流处理: {clientId}, 文件: {filePath}"); } - + else + { + _logger.LogError($"无法开始音频流处理,添加到活动录音失败: {clientId}"); + writer.Dispose(); + } + } + catch (Exception ex) + { + _logger.LogError($"开始音频流处理失败: {ex.Message}"); + } + + return Task.CompletedTask; + } + + /// + /// 结束音频流处理 + /// + /// 客户端ID + /// 取消令牌 + /// 异步任务 + public Task EndAudioStreamAsync(string clientId, CancellationToken token = default) + { + if (string.IsNullOrEmpty(clientId)) + { + throw new ArgumentNullException(nameof(clientId)); + } + + try + { + // 移除并处理现有的录音 + if (_activeRecordings.TryRemove(clientId, out var writer)) + { + _recordingFilePaths.TryGetValue(clientId, out var filePath); + + // 关闭和释放写入器 + writer.Close(); + writer.Dispose(); + + _logger.LogInformation($"结束音频流: {clientId}"); + + // 如果有文件路径,触发事件 + if (!string.IsNullOrEmpty(filePath)) + { + // 触发音频保存到文件事件 + OnAudioSavedToFile(filePath); + } + } + } + catch (Exception ex) + { + _logger.LogError($"结束音频流处理失败: {ex.Message}"); + } + + return Task.CompletedTask; + } + + /// + /// 获取会话的录音文件路径 + /// + /// 客户端ID + /// 录音文件路径,如果没有则返回null + public string GetRecordingFilePath(string clientId) + { + if (string.IsNullOrEmpty(clientId)) + { return null; } - /// - /// 获取当前正在处理的音频流 - /// - /// 客户端ID - /// 音频流 - public Stream GetCurrentAudioStream(string clientId) + if (_recordingFilePaths.TryGetValue(clientId, out var filePath)) { - if (string.IsNullOrEmpty(clientId)) - { - throw new ArgumentNullException(nameof(clientId)); - } - - if (!_recordingFilePaths.TryGetValue(clientId, out var filePath) || !File.Exists(filePath)) - { - return null; - } - - try - { - // 由于录音正在进行中,我们返回一个只读的流 - return new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - } - catch (Exception ex) - { - _logger.LogError($"获取当前音频流失败: {ex.Message}"); - return null; - } + return filePath; } - /// - /// 获取最近的录音文件 - /// - /// 文件数量 - /// 录音文件信息数组(文件名) - public string[] GetRecentRecordings(int count = 10) + return null; + } + + /// + /// 获取当前正在处理的音频流 + /// + /// 客户端ID + /// 音频流 + public Stream GetCurrentAudioStream(string clientId) + { + if (string.IsNullOrEmpty(clientId)) { - try - { - var recordingsFolder = Path.Combine(Directory.GetCurrentDirectory(), _configService.CurrentConfig.Recording.RecordingsFolder); - _logger.LogInformation($"查找录音文件夹: {recordingsFolder}"); - - if (!Directory.Exists(recordingsFolder)) - { - _logger.LogWarning($"录音文件夹不存在: {recordingsFolder}"); - Directory.CreateDirectory(recordingsFolder); - _logger.LogInformation($"已创建录音文件夹: {recordingsFolder}"); - return Array.Empty(); - } + throw new ArgumentNullException(nameof(clientId)); + } - // 获取所有WAV文件并按最近修改时间排序 - var files = Directory.GetFiles(recordingsFolder, "*.wav") - .Select(f => new FileInfo(f)) - .OrderByDescending(f => f.CreationTime) - .Take(count) - .ToArray(); + if (!_recordingFilePaths.TryGetValue(clientId, out var filePath) || !File.Exists(filePath)) + { + return null; + } - _logger.LogInformation($"找到 {files.Length} 个录音文件"); - - // 只返回文件名,不包含路径 - var fileNames = files.Select(f => f.Name).ToArray(); - - _logger.LogInformation($"返回录音文件: {string.Join(", ", fileNames)}"); - return fileNames; - } - catch (Exception ex) + try + { + // 由于录音正在进行中,我们返回一个只读的流 + return new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + catch (Exception ex) + { + _logger.LogError($"获取当前音频流失败: {ex.Message}"); + return null; + } + } + + /// + /// 获取最近的录音文件 + /// + /// 文件数量 + /// 录音文件信息数组(文件名) + public string[] GetRecentRecordings(int count = 10) + { + try + { + var recordingsFolder = Path.Combine(Directory.GetCurrentDirectory(), _configService.CurrentConfig.Recording.RecordingsFolder); + _logger.LogInformation($"查找录音文件夹: {recordingsFolder}"); + + if (!Directory.Exists(recordingsFolder)) { - _logger.LogError($"获取最近录音失败: {ex.Message}"); + _logger.LogWarning($"录音文件夹不存在: {recordingsFolder}"); + Directory.CreateDirectory(recordingsFolder); + _logger.LogInformation($"已创建录音文件夹: {recordingsFolder}"); return Array.Empty(); } + + // 获取所有WAV文件并按最近修改时间排序 + var files = Directory.GetFiles(recordingsFolder, "*.wav") + .Select(f => new FileInfo(f)) + .OrderByDescending(f => f.CreationTime) + .Take(count) + .ToArray(); + + _logger.LogInformation($"找到 {files.Length} 个录音文件"); + + // 只返回文件名,不包含路径 + var fileNames = files.Select(f => f.Name).ToArray(); + + _logger.LogInformation($"返回录音文件: {string.Join(", ", fileNames)}"); + return fileNames; } - - /// - /// 清理旧录音 - /// - private void CleanupOldRecordings() + catch (Exception ex) { - try - { - var config = _configService.CurrentConfig.Recording; - var recordingsFolder = Path.Combine(Directory.GetCurrentDirectory(), config.RecordingsFolder); - if (!Directory.Exists(recordingsFolder)) - { - return; - } - - var threshold = DateTime.Now.AddDays(-config.KeepRecordingsDays); - var oldFiles = Directory.GetFiles(recordingsFolder, "*.wav") - .Select(f => new FileInfo(f)) - .Where(f => f.LastWriteTime < threshold) - .ToArray(); - - foreach (var file in oldFiles) - { - try - { - file.Delete(); - _logger.LogInformation($"已删除旧录音: {file.FullName}"); - } - catch (Exception ex) - { - _logger.LogWarning($"删除旧录音失败: {file.FullName}, 错误: {ex.Message}"); - } - } - - _logger.LogInformation($"清理旧录音完成,删除了 {oldFiles.Length} 个文件"); - } - catch (Exception ex) - { - _logger.LogError($"清理旧录音失败: {ex.Message}"); - } + _logger.LogError($"获取最近录音失败: {ex.Message}"); + return Array.Empty(); } + } - /// - /// 触发音频保存事件 - /// - /// 文件路径 - protected virtual void OnAudioSavedToFile(string filePath) + /// + /// 清理旧录音 + /// + private void CleanupOldRecordings() + { + try { - AudioSavedToFile?.Invoke(this, filePath); - } - - /// - /// 触发语音转文字结果事件 - /// - /// 结果 - protected virtual void OnSpeechToTextResultReceived(SpeechToTextResult result) - { - SpeechToTextResultReceived?.Invoke(this, result); - } - - /// - /// 释放资源 - /// - public void Dispose() - { - if (_isDisposed) + var config = _configService.CurrentConfig.Recording; + var recordingsFolder = Path.Combine(Directory.GetCurrentDirectory(), config.RecordingsFolder); + if (!Directory.Exists(recordingsFolder)) { return; } - _logger.LogInformation("释放音频处理服务资源"); + var threshold = DateTime.Now.AddDays(-config.KeepRecordingsDays); + var oldFiles = Directory.GetFiles(recordingsFolder, "*.wav") + .Select(f => new FileInfo(f)) + .Where(f => f.LastWriteTime < threshold) + .ToArray(); - // 结束所有活动的录音 - foreach (var clientId in _activeRecordings.Keys.ToArray()) + foreach (var file in oldFiles) { try { - EndAudioStreamAsync(clientId).Wait(); + file.Delete(); + _logger.LogInformation($"已删除旧录音: {file.FullName}"); } catch (Exception ex) { - _logger.LogError($"结束活动录音失败: {clientId}, 错误: {ex.Message}"); + _logger.LogWarning($"删除旧录音失败: {file.FullName}, 错误: {ex.Message}"); } } - _isDisposed = true; + _logger.LogInformation($"清理旧录音完成,删除了 {oldFiles.Length} 个文件"); + } + catch (Exception ex) + { + _logger.LogError($"清理旧录音失败: {ex.Message}"); } } -} \ No newline at end of file + + /// + /// 触发音频保存事件 + /// + /// 文件路径 + protected virtual void OnAudioSavedToFile(string filePath) + { + AudioSavedToFile?.Invoke(this, filePath); + } + + /// + /// 触发语音转文字结果事件 + /// + /// 结果 + protected virtual void OnSpeechToTextResultReceived(SpeechToTextResult result) + { + SpeechToTextResultReceived?.Invoke(this, result); + } + + /// + /// 释放资源 + /// + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _logger.LogInformation("释放音频处理服务资源"); + + // 结束所有活动的录音 + foreach (var clientId in _activeRecordings.Keys.ToArray()) + { + try + { + EndAudioStreamAsync(clientId).Wait(); + } + catch (Exception ex) + { + _logger.LogError($"结束活动录音失败: {clientId}, 错误: {ex.Message}"); + } + } + + _isDisposed = true; + } +} + +// 简单的噪声门实现 +public class NoiseGateSampleProvider : ISampleProvider +{ + private readonly ISampleProvider source; + private float threshold; + private float attackSeconds; + private float releaseSeconds; + private float envelope; + private float gain; + + public NoiseGateSampleProvider(ISampleProvider source) + { + this.source = source; + this.WaveFormat = source.WaveFormat; + } + + public float Threshold + { + get => threshold; + set => threshold = Math.Max(0, Math.Min(1, value)); + } + + public float AttackSeconds + { + get => attackSeconds; + set => attackSeconds = Math.Max(0.001f, value); + } + + public float ReleaseSeconds + { + get => releaseSeconds; + set => releaseSeconds = Math.Max(0.001f, value); + } + + public WaveFormat WaveFormat { get; } + + public int Read(float[] buffer, int offset, int count) + { + int samplesRead = source.Read(buffer, offset, count); + + float attackCoeff = (float)Math.Exp(-1.0 / (WaveFormat.SampleRate * attackSeconds)); + float releaseCoeff = (float)Math.Exp(-1.0 / (WaveFormat.SampleRate * releaseSeconds)); + + for (int n = 0; n < samplesRead; n++) + { + float sample = buffer[offset + n]; + float absSample = Math.Abs(sample); + + // 包络跟踪 + if (absSample > envelope) + envelope = absSample; + else + envelope *= (absSample > threshold) ? attackCoeff : releaseCoeff; + + // 应用增益 + if (envelope > threshold) + gain = 1.0f; + else + gain = 0.0f; + + buffer[offset + n] = sample * gain; + } + + return samplesRead; + } +} + +// 简单的BiQuad滤波器包装 +public class BiQuadFilterSampleProvider : ISampleProvider +{ + private readonly ISampleProvider source; + private BiQuadFilter filter; + + public BiQuadFilterSampleProvider(ISampleProvider source) + { + this.source = source; + this.WaveFormat = source.WaveFormat; + } + + public BiQuadFilter Filter + { + get => filter; + set => filter = value; + } + + public WaveFormat WaveFormat { get; } + + public int Read(float[] buffer, int offset, int count) + { + int samplesRead = source.Read(buffer, offset, count); + + if (filter != null) + { + for (int n = 0; n < samplesRead; n++) + { + buffer[offset + n] = filter.Transform(buffer[offset + n]); + } + } + + return samplesRead; + } +} + +public class ImprovedNoiseGate : ISampleProvider +{ + private readonly ISampleProvider source; + private float threshold; + private float attackSeconds; + private float releaseSeconds; + private float holdSeconds; + private float envelope; + private bool gateOpen; + private int holdCountRemaining; + + public ImprovedNoiseGate(ISampleProvider source) + { + this.source = source ?? throw new ArgumentNullException(nameof(source)); + this.WaveFormat = source.WaveFormat; + + // 默认参数 + Threshold = 0.015f; + AttackSeconds = 0.05f; + ReleaseSeconds = 0.3f; + HoldSeconds = 0.2f; + } + + public WaveFormat WaveFormat { get; } + + /// + /// 噪声门阈值 (0.0-1.0) + /// + public float Threshold + { + get => threshold; + set => threshold = Math.Max(0.0f, Math.Min(1.0f, value)); + } + + /// + /// 启动时间 (秒) + /// + public float AttackSeconds + { + get => attackSeconds; + set => attackSeconds = Math.Max(0.001f, value); + } + + /// + /// 释放时间 (秒) + /// + public float ReleaseSeconds + { + get => releaseSeconds; + set => releaseSeconds = Math.Max(0.001f, value); + } + + /// + /// 保持时间 (秒),在信号低于阈值后保持门打开的时间 + /// + public float HoldSeconds + { + get => holdSeconds; + set => holdSeconds = Math.Max(0.0f, value); + } + + /// + /// 当前包络值 (只读) + /// + public float CurrentEnvelope => envelope; + + /// + /// 当前门状态 (只读) + /// + public bool IsGateOpen => gateOpen; + + public int Read(float[] buffer, int offset, int count) + { + int samplesRead = source.Read(buffer, offset, count); + + // 预计算系数 + float attackCoeff = CalculateCoefficient(AttackSeconds); + float releaseCoeff = CalculateCoefficient(ReleaseSeconds); + int holdSamples = (int)(WaveFormat.SampleRate * HoldSeconds); + + for (int n = 0; n < samplesRead; n++) + { + float sample = buffer[offset + n]; + float absSample = Math.Abs(sample); + + // 更新包络 + if (absSample > envelope) + { + envelope = absSample + (envelope - absSample) * attackCoeff; + } + else + { + envelope = absSample + (envelope - absSample) * releaseCoeff; + } + + // 更新门状态 + if (envelope > Threshold) + { + gateOpen = true; + holdCountRemaining = holdSamples; // 重置保持计数器 + } + else if (holdCountRemaining > 0) + { + holdCountRemaining--; + } + else + { + gateOpen = false; + } + + // 应用增益 (带平滑过渡) + float gain = gateOpen ? 1.0f : CalculateSoftGain(envelope); + buffer[offset + n] = sample * gain; + } + + return samplesRead; + } + + private float CalculateCoefficient(float timeInSeconds) + { + if (timeInSeconds <= 0.0f) return 0.0f; + return (float)Math.Exp(-1.0 / (WaveFormat.SampleRate * timeInSeconds)); + } + + private float CalculateSoftGain(float env) + { + // 软过渡:当包络接近阈值时逐渐降低增益 + if (env >= Threshold) return 1.0f; + + // 计算相对阈值的位置 (0.0-1.0) + float relativePosition = env / Threshold; + + // 三次方曲线实现平滑过渡 + return relativePosition * relativePosition * relativePosition; + } + + /// + /// 重置噪声门状态 + /// + public void Reset() + { + envelope = 0.0f; + gateOpen = false; + holdCountRemaining = 0; + } +} +// 新增平滑处理器 +public class SmoothingSampleProvider : ISampleProvider +{ + private readonly ISampleProvider source; + private readonly float[] history; + private int historyIndex; + + public SmoothingSampleProvider(ISampleProvider source, int windowSize = 5) + { + this.source = source; + this.history = new float[windowSize]; + this.WaveFormat = source.WaveFormat; + } + + public WaveFormat WaveFormat { get; } + + public int Read(float[] buffer, int offset, int count) + { + int samplesRead = source.Read(buffer, offset, count); + + for (int n = 0; n < samplesRead; n++) + { + history[historyIndex] = buffer[offset + n]; + historyIndex = (historyIndex + 1) % history.Length; + + // 简单移动平均平滑 + float sum = 0; + for (int i = 0; i < history.Length; i++) + sum += history[i]; + + buffer[offset + n] = sum / history.Length; + } + + return samplesRead; + } +} \ No newline at end of file diff --git a/ShengShengBuXi/Services/IAudioProcessingService.cs b/ShengShengBuXi/Services/IAudioProcessingService.cs index 7b588da..d8e8f0d 100644 --- a/ShengShengBuXi/Services/IAudioProcessingService.cs +++ b/ShengShengBuXi/Services/IAudioProcessingService.cs @@ -15,18 +15,18 @@ namespace ShengShengBuXi.Services /// 当有新的语音转文字结果时触发 /// event EventHandler SpeechToTextResultReceived; - + /// /// 当音频数据被保存为文件时触发 /// event EventHandler AudioSavedToFile; - + /// /// 初始化音频处理服务 /// /// 初始化是否成功 bool Initialize(); - + /// /// 处理接收到的音频数据 /// @@ -37,7 +37,7 @@ namespace ShengShengBuXi.Services /// 取消令牌 /// 异步任务 Task ProcessAudioDataAsync(byte[] audioData, int sampleRate, int channels, string clientId, CancellationToken token = default); - + /// /// 开始新的音频流处理 /// @@ -47,7 +47,7 @@ namespace ShengShengBuXi.Services /// 取消令牌 /// 异步任务 Task StartAudioStreamAsync(string clientId, int sampleRate, int channels, CancellationToken token = default); - + /// /// 结束音频流处理 /// @@ -55,26 +55,41 @@ namespace ShengShengBuXi.Services /// 取消令牌 /// 异步任务 Task EndAudioStreamAsync(string clientId, CancellationToken token = default); - + /// /// 获取当前正在处理的音频流 /// /// 客户端ID /// 音频流 Stream GetCurrentAudioStream(string clientId); - + /// /// 获取最近的录音文件 /// /// 文件数量 /// 录音文件路径列表 string[] GetRecentRecordings(int count = 10); - + /// /// 获取会话的录音文件路径 /// /// 客户端ID /// 录音文件路径,如果没有则返回null string GetRecordingFilePath(string clientId); + + + /// + /// 应用噪声消除 + /// + /// 音频数据 + /// 采样率 + /// 声道数 + /// 噪声门限值 + /// 攻击时间 + /// 释放时间 + /// 高通滤波器截止频率(Hz) + /// 滤波器Q值 + /// + byte[] ApplyNoiseReduction(byte[] audioData, int sampleRate = 16000, int channels = 1, float noiseThreshold = 0.02f, float attackSeconds = 0.01f, float releaseSeconds = 0.1f, int highPassCutoff = 80, float q = 1.0f); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/ShengShengBuXi/Services/SpeechToTextService.cs b/ShengShengBuXi/Services/SpeechToTextService.cs index b16f2e2..d764d6d 100644 --- a/ShengShengBuXi/Services/SpeechToTextService.cs +++ b/ShengShengBuXi/Services/SpeechToTextService.cs @@ -562,7 +562,7 @@ namespace ShengShengBuXi.Services await ProcessAudioAsync(audioData, sessionId, token); // 等待一段时间,模拟实时处理 - await Task.Delay(100, token); + await Task.Delay(20, token); } // 结束会话 diff --git a/ShengShengBuXi/config.json b/ShengShengBuXi/config.json index 52ecda0..24f5904 100644 --- a/ShengShengBuXi/config.json +++ b/ShengShengBuXi/config.json @@ -39,6 +39,7 @@ "reconnectDelayMs": 2000, "enableSpeechToText": true, "enableAudioStreaming": true, + "EnableAudioNoiseReduction": false, "heartbeatIntervalSeconds": 15, "tencentCloudASR": { "appId": "",