DotNet 6下SignalR音频流式转写服务端变量重置问题求助
问题:SignalR流式音频转写中服务端实例变量重置导致识别失败
需求
我希望在DotNet 6环境下,通过SignalR将客户端音频流式传输至服务端,完成语音转写后将文本返回给客户端。
当前实现
服务端代码
public class AudioRecordingHub : Hub { private SpeechRecognizer recognizer; private MemoryStream audioStream; private bool isTranscribing = false; public async Task SendAudio(string base64Audio) { byte[] audioBytes = Convert.FromBase64String(base64Audio); await audioStream.WriteAsync(audioBytes); if (!isTranscribing) { isTranscribing = true; await StartTranscription(); } } public async Task StopTranscription() { if (recognizer != null) { await recognizer.StopContinuousRecognitionAsync(); isTranscribing = false; } } private async Task StartTranscription() { var speechConfig = SpeechConfig.FromSubscription("<key>", "<region>"); speechConfig.SetProperty("SpeechServiceResponse_OutputFormatOption", "Simple"); var callback = new CustomPullAudioInputStreamCallback(audioStream); var inputStream = AudioInputStream.CreatePullStream(callback); var audioConfig = AudioConfig.FromStreamInput(inputStream); recognizer = new SpeechRecognizer(speechConfig, audioConfig); recognizer.Recognized += (s, e) => { if (e.Result.Reason == ResultReason.RecognizedSpeech) { // Send transcribed text to the client Clients.Caller.SendAsync("ReceiveText", e.Result.Text); } }; await recognizer.StartContinuousRecognitionAsync(); } } public class CustomPullAudioInputStreamCallback : PullAudioInputStreamCallback { private readonly MemoryStream _audioStream; private BinaryReader _binaryReader; public CustomPullAudioInputStreamCallback(MemoryStream audioStream) { _audioStream = audioStream; _binaryReader = new BinaryReader(_audioStream); } public override int Read(byte[] dataBuffer, uint size) { return _binaryReader.Read(dataBuffer, 0, (int)size); } public override void Close() { _binaryReader.Dispose(); } }
客户端代码
recordRTC = RecordRTC(stream, { type: 'audio', mimeType: 'audio/webm', timeSlice: 1000, // Send audio chunks every 1000 ms (1 second) ondataavailable: async (blob) => { if (connection && connection.state === signalR.HubConnectionState.Connected) { const base64Audio = await blobToBase64(blob); await connection.invoke('SendAudio', base64Audio); } } }); const blobToBase64 = (blob) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = reject; reader.onload = () => { resolve(reader.result.split(',')[1]); }; reader.readAsDataURL(blob); });
Program.cs配置
.AddSignalR(options => { options.EnableDetailedErrors = true; options.MaximumReceiveMessageSize = 1024 * 1024 * 10; // Allow messages up to 10 MB in size }); app .UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); endpoints.MapHub<AudioRecordingHub>("/audiorecordinghub"); });
Chrome日志信息
- RecordRTC version: 5.6.1
- RecordRTC.min.js:15 started recording audio stream.
- RecordRTC.min.js:15 Using recorderType: MediaStreamRecorder
- RecordRTC.min.js:15 Passing following config over MediaRecorder API. {type: 'audio', mimeType: 'audio/webm', timeSlice: 1000, ondataavailable: ƒ, initCallback: ƒ, …}
- RecordRTC.min.js:15 Recorder state changed: recording
- RecordRTC.min.js:15 Initialized recorderType: MediaStreamRecorder for output-type: audio
- signalr.min.js:1 [2023-04-05T15:05:25.125Z] Information: Normalizing '/audiorecordinghub' to 'https://localhost:7206/audiorecordinghub'.
- signalr.min.js:1 [2023-04-05T15:05:25.133Z] Information: WebSocket connected to wss://localhost:7206/audiorecordinghub?id=u1vAsklbwmPeV_MDvkp1mw.
问题现象
每次调用SendAudio方法时,服务端的以下实例变量都会重置:
private SpeechRecognizer recognizer; private MemoryStream audioStream = new(); private bool isTranscribing = false;
导致SpeechRecognizer的Recognized事件从未触发。
已尝试的解决方法
- 查阅官方文档
- 谷歌搜索相关问题
- 借助AI工具排查
解决方案
核心原因
SignalR Hub默认是**瞬时(transient)**生命周期,每次客户端调用Hub方法时,都会创建一个新的Hub实例,所以实例变量会被重置。需要将每个客户端的音频会话数据与连接关联,而不是存储在Hub实例中。
修正步骤
- 创建会话状态类:存储每个连接的音频流、识别器和转写状态
public class AudioSession { public SpeechRecognizer Recognizer { get; set; } public MemoryStream AudioStream { get; set; } = new MemoryStream(); public bool IsTranscribing { get; set; } = false; }
- 使用字典存储会话:通过连接ID关联会话,使用
ConcurrentDictionary保证线程安全
public class AudioRecordingHub : Hub { // 全局存储所有客户端的音频会话,线程安全 private static readonly ConcurrentDictionary<string, AudioSession> _audioSessions = new(); public async Task SendAudio(string base64Audio) { // 获取当前连接的会话,不存在则创建新会话 var session = _audioSessions.GetOrAdd(Context.ConnectionId, _ => new AudioSession()); byte[] audioBytes = Convert.FromBase64String(base64Audio); await session.AudioStream.WriteAsync(audioBytes); if (!session.IsTranscribing) { session.IsTranscribing = true; await StartTranscription(session); } } public async Task StopTranscription() { if (_audioSessions.TryRemove(Context.ConnectionId, out var session)) { if (session.Recognizer != null) { await session.Recognizer.StopContinuousRecognitionAsync(); session.Recognizer.Dispose(); session.AudioStream.Dispose(); } } } private async Task StartTranscription(AudioSession session) { var speechConfig = SpeechConfig.FromSubscription("<key>", "<region>"); speechConfig.SetProperty("SpeechServiceResponse_OutputFormatOption", "Simple"); var callback = new CustomPullAudioInputStreamCallback(session.AudioStream); var inputStream = AudioInputStream.CreatePullStream(callback); var audioConfig = AudioConfig.FromStreamInput(inputStream); session.Recognizer = new SpeechRecognizer(speechConfig, audioConfig); session.Recognizer.Recognized += (s, e) => { if (e.Result.Reason == ResultReason.RecognizedSpeech) { // 确保发送到当前连接的客户端 Clients.Client(Context.ConnectionId).SendAsync("ReceiveText", e.Result.Text); } }; // 添加识别错误处理 session.Recognizer.Recognizing += (s, e) => { if (e.Result.Reason == ResultReason.NoMatch) { Clients.Client(Context.ConnectionId).SendAsync("ReceiveText", "[未识别到语音]"); } }; await session.Recognizer.StartContinuousRecognitionAsync(); } // 客户端断开连接时清理会话 public override async Task OnDisconnectedAsync(Exception exception) { if (_audioSessions.TryRemove(Context.ConnectionId, out var session)) { if (session.Recognizer != null) { await session.Recognizer.StopContinuousRecognitionAsync(); session.Recognizer.Dispose(); } session.AudioStream.Dispose(); } await base.OnDisconnectedAsync(exception); } }
- 修正音频流读取逻辑:原
CustomPullAudioInputStreamCallback的Read方法会一次性读完流,调整为支持持续读取新写入的音频数据
public class CustomPullAudioInputStreamCallback : PullAudioInputStreamCallback { private readonly MemoryStream _audioStream; private readonly object _lockObj = new object(); public CustomPullAudioInputStreamCallback(MemoryStream audioStream) { _audioStream = audioStream; } public override int Read(byte[] dataBuffer, uint size) { lock (_lockObj) { // 获取当前流的位置,读取可用数据 var bytesAvailable = (int)(_audioStream.Length - _audioStream.Position); if (bytesAvailable == 0) { // 没有可用数据时等待,避免频繁空读 Thread.Sleep(100); return 0; } var bytesToRead = Math.Min((int)size, bytesAvailable); return _audioStream.Read(dataBuffer, 0, bytesToRead); } } public override void Close() { _audioStream.Dispose(); } }
- 客户端补充断开处理:确保停止录音时调用服务端
StopTranscription方法
// 停止录音时调用 async function stopRecording() { if (recordRTC) { recordRTC.stopRecording(); await connection.invoke('StopTranscription'); } } // 页面关闭时清理 window.addEventListener('beforeunload', async () => { if (connection && connection.state === signalR.HubConnectionState.Connected) { await connection.invoke('StopTranscription'); await connection.stop(); } });
关键说明
- 生命周期问题:SignalR Hub实例是每次请求创建,必须用全局存储(如
ConcurrentDictionary)关联连接ID和会话数据 - 线程安全:使用
ConcurrentDictionary和锁保证多线程下的会话操作安全 - 资源清理:在客户端断开或停止转写时,及时释放
SpeechRecognizer和MemoryStream资源,避免内存泄漏 - 音频格式兼容:确保客户端录制的音频格式与Azure Speech Service要求一致(推荐PCM 16kHz单声道,若使用WebM需确认服务端是否支持,或客户端转换格式)
内容的提问来源于stack exchange,提问作者AllramEst
相关产品推荐
相关产品推荐

