You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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实例中。

修正步骤

  1. 创建会话状态类:存储每个连接的音频流、识别器和转写状态
public class AudioSession
{
    public SpeechRecognizer Recognizer { get; set; }
    public MemoryStream AudioStream { get; set; } = new MemoryStream();
    public bool IsTranscribing { get; set; } = false;
}
  1. 使用字典存储会话:通过连接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);
    }
}
  1. 修正音频流读取逻辑:原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();
    }
}
  1. 客户端补充断开处理:确保停止录音时调用服务端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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.26 04:55:10