using System.Threading.Channels; using MeetingAssistant.Transcription; using Microsoft.CognitiveServices.Speech; using Microsoft.CognitiveServices.Speech.Audio; using Microsoft.CognitiveServices.Speech.Transcription; using Microsoft.Extensions.Options; namespace MeetingAssistant.Speakers; public sealed class AzureSpeechSpeakerIdentityDiarizationClient : ISpeakerIdentityDiarizationClient { private readonly AzureSpeechOptions options; private readonly ILogger logger; public AzureSpeechSpeakerIdentityDiarizationClient( IOptions options, ILogger logger) { this.options = options.Value.SpeakerIdentification.AzureSpeech; this.logger = logger; } public async Task> DiarizeAsync( string wavPath, CancellationToken cancellationToken) { var key = AzureSpeechStreamingTranscriptionProvider.ResolveKey(options); if (string.IsNullOrWhiteSpace(key)) { logger.LogWarning( "Speaker identity Azure Speech matching is enabled but no key is configured directly or via {KeyEnv}", options.KeyEnv); return []; } var speechConfig = string.IsNullOrWhiteSpace(options.Region) ? SpeechConfig.FromEndpoint(new Uri(options.Endpoint), key) : SpeechConfig.FromSubscription(key, options.Region); speechConfig.SpeechRecognitionLanguage = ResolveRecognitionLanguage(options); speechConfig.OutputFormat = OutputFormat.Detailed; speechConfig.SetProperty( PropertyId.SpeechServiceResponse_DiarizeIntermediateResults, options.DiarizeIntermediateResults ? "true" : "false"); using var audioConfig = AudioConfig.FromWavFileInput(wavPath); using var recognizer = new ConversationTranscriber(speechConfig, audioConfig); var channel = Channel.CreateUnbounded(); recognizer.Transcribed += (_, args) => { if (args.Result.Reason != ResultReason.RecognizedSpeech) { return; } var start = TimeSpan.FromTicks(args.Result.OffsetInTicks); channel.Writer.TryWrite(new TranscriptionSegment( start, start + args.Result.Duration, NormalizeSpeakerId(args.Result.SpeakerId), args.Result.Text.Trim())); }; recognizer.Canceled += (_, args) => { if (args.Reason == CancellationReason.Error) { channel.Writer.TryComplete(new InvalidOperationException( $"Azure Speech identity matching canceled: {args.ErrorCode} {args.ErrorDetails}")); return; } channel.Writer.TryComplete(); }; recognizer.SessionStopped += (_, _) => channel.Writer.TryComplete(); await recognizer.StartTranscribingAsync(); var segments = new List(); try { await foreach (var segment in channel.Reader.ReadAllAsync(cancellationToken)) { segments.Add(segment); } } finally { try { var stopTask = recognizer.StopTranscribingAsync(); if (options.RecognitionStopTimeout > TimeSpan.Zero) { await stopTask.WaitAsync(options.RecognitionStopTimeout, CancellationToken.None); } else { await stopTask.WaitAsync(CancellationToken.None); } } catch (TimeoutException) { logger.LogWarning("Timed out while stopping Azure Speech identity diarization recognizer"); } } return segments; } internal static string ResolveRecognitionLanguage(AzureSpeechOptions options) { if (!options.Language.Equals("auto", StringComparison.OrdinalIgnoreCase)) { return options.Language; } return options.AutoDetectLanguages .Select(language => language.Trim()) .FirstOrDefault(language => !string.IsNullOrWhiteSpace(language)) ?? "de-DE"; } private static string NormalizeSpeakerId(string? speakerId) { return string.IsNullOrWhiteSpace(speakerId) || speakerId.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ? "Unknown" : speakerId; } }