using System.Threading.Channels; using MeetingAssistant.Recording; using Microsoft.CognitiveServices.Speech; using Microsoft.CognitiveServices.Speech.Audio; using Microsoft.CognitiveServices.Speech.Transcription; using Microsoft.Extensions.Options; namespace MeetingAssistant.Transcription; public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTranscriptionProvider { private readonly MeetingAssistantOptions options; private readonly ILogger logger; public AzureSpeechStreamingTranscriptionProvider( IOptions options, ILogger logger) { this.options = options.Value; this.logger = logger; } public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await using var enumerator = audio.GetAsyncEnumerator(cancellationToken); if (!await enumerator.MoveNextAsync()) { yield break; } var firstChunk = enumerator.Current; using var pushStream = AudioInputStream.CreatePushStream( AudioStreamFormat.GetWaveFormatPCM( (uint)firstChunk.SampleRate, 16, (byte)firstChunk.Channels)); using var audioConfig = AudioConfig.FromStreamInput(pushStream); var speechConfig = CreateSpeechConfig(); using var recognizer = new ConversationTranscriber(speechConfig, audioConfig); var segments = Channel.CreateUnbounded(); recognizer.Transcribed += (_, args) => { if (args.Result.Reason != ResultReason.RecognizedSpeech || string.IsNullOrWhiteSpace(args.Result.Text)) { return; } var start = TimeSpan.FromTicks(args.Result.OffsetInTicks); var segment = new TranscriptionSegment( start, start + args.Result.Duration, NormalizeSpeakerId(args.Result.SpeakerId), args.Result.Text.Trim()); logger.LogInformation( "Azure Speech emitted segment at {Start} from {Speaker}: {Text}", segment.Start, segment.Speaker, segment.Text); segments.Writer.TryWrite(segment); }; recognizer.Canceled += (_, args) => { if (args.Reason == CancellationReason.Error) { segments.Writer.TryComplete(new InvalidOperationException( $"Azure Speech recognition canceled: {args.ErrorCode} {args.ErrorDetails}")); return; } segments.Writer.TryComplete(); }; await recognizer.StartTranscribingAsync(); var pumpTask = Task.Run( () => PumpAudioAsync( firstChunk, enumerator, pushStream, recognizer, segments.Writer, options.AzureSpeech.RecognitionStopTimeout, cancellationToken), CancellationToken.None); await foreach (var segment in segments.Reader.ReadAllAsync(cancellationToken)) { yield return segment; } await pumpTask.WaitAsync(cancellationToken); } private SpeechConfig CreateSpeechConfig() { var azure = options.AzureSpeech; var key = ResolveKey(azure); if (string.IsNullOrWhiteSpace(key)) { throw new InvalidOperationException( $"Azure Speech key is not configured. Set AzureSpeech:Key or environment variable {azure.KeyEnv}."); } var speechConfig = string.IsNullOrWhiteSpace(azure.Region) ? SpeechConfig.FromEndpoint(new Uri(azure.Endpoint), key) : SpeechConfig.FromSubscription(key, azure.Region); speechConfig.SpeechRecognitionLanguage = azure.Language; speechConfig.OutputFormat = OutputFormat.Detailed; speechConfig.SetProperty( PropertyId.SpeechServiceResponse_DiarizeIntermediateResults, azure.DiarizeIntermediateResults ? "true" : "false"); return speechConfig; } private static async Task PumpAudioAsync( AudioChunk firstChunk, IAsyncEnumerator audio, PushAudioInputStream pushStream, ConversationTranscriber recognizer, ChannelWriter segments, TimeSpan recognitionStopTimeout, CancellationToken cancellationToken) { try { WriteChunk(pushStream, firstChunk, firstChunk); while (await audio.MoveNextAsync()) { cancellationToken.ThrowIfCancellationRequested(); WriteChunk(pushStream, firstChunk, audio.Current); } pushStream.Close(); try { var stopTask = recognizer.StopTranscribingAsync(); if (recognitionStopTimeout > TimeSpan.Zero) { await stopTask.WaitAsync(recognitionStopTimeout, cancellationToken); } else { await stopTask.WaitAsync(cancellationToken); } } catch (TimeoutException) { // The SDK can outlive short diagnostic streams while waiting for final service events. } segments.TryComplete(); } catch (Exception exception) { segments.TryComplete(exception); } } private static void WriteChunk(PushAudioInputStream pushStream, AudioChunk expectedFormat, AudioChunk chunk) { if (chunk.SampleRate != expectedFormat.SampleRate || chunk.Channels != expectedFormat.Channels) { return; } pushStream.Write(chunk.Pcm); } private static string NormalizeSpeakerId(string? speakerId) { return string.IsNullOrWhiteSpace(speakerId) || speakerId.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ? "Unknown" : speakerId; } private static string ResolveKey(AzureSpeechOptions options) { if (!string.IsNullOrWhiteSpace(options.Key)) { return options.Key; } return string.IsNullOrWhiteSpace(options.KeyEnv) ? "" : Environment.GetEnvironmentVariable(options.KeyEnv) ?? ""; } }