Предисловие: зачем вообще это нужно
Представьте сценарий: вы ведёте встречу на английском с иностранными коллегами, и кто-то хочет получить стенограмму с привязкой к спикерам — и сразу переведённую, к примеру, на итальянский. Или вы транскрибируете интервью для статьи. Или просто хочется поиграться с Azure Speech Services.
Вот именно с этим набором мотивов родился открытый проект AzioSpeech — десктопное Windows-приложение, написанное на C# / .NET 9, с UI на Avalonia UI, которое умеет:
-
захватывать аудио с микрофона через NAudio;
-
транскрибировать речь в реальном времени через Azure Cognitive Services Speech SDK;
-
определять, кто из говорящих что сказал (speaker diarization);
-
переводить на 9 языков;
-
сохранять результат в
.txt,.jsonили.srt.
Технологический стек: что взяли и почему
.NET 9 + Avalonia 11 + NAudio + ReactiveUI + Microsoft.CognitiveServices.Speech
Avalonia выбрана вместо WPF или WinUI осознанно: UI-фреймворк сам по себе кросс-платформенный и прекрасно работает под Linux и macOS, но приложение намеренно нацелено только на Windows. Причина проста — захват звука реализован через NAudio с использованием WinMM API, которое существует исключительно в Windows.
NAudio — пожалуй, самая зрелая аудиобиблиотека в экосистеме .NET. Используется для захвата сырого PCM-потока с микрофона через класс WaveInEvent, который работает поверх Windows Multimedia API (WinMM). Это нативный Windows-механизм — только waveInOpen под капотом. Именно использование WaveInEvent с его WinMM-бэкендом и делает всё приложение строго Win-only, несмотря на Avalonia в стеке.
ReactiveUI — реактивный MVVM-фреймворк. В связке с ReactiveUI.Fody позволяет избавиться от ручного INotifyPropertyChanged. Достаточно поставить атрибут [Reactive] над свойством — Fody после компиляции модифицирует IL-код сборки, вставляя всю обвязку автоматически:
// TranscriptionViewModel.cs — свойства ViewModel без единой строки INotifyPropertyChanged[Reactive] public string CurrentTranscript { get; private set; } = string.Empty;[Reactive] public string CurrentTranslation { get; private set; } = string.Empty;[Reactive] public bool IsRecording { get; set; }[Reactive] public bool EnableTranslation { get; set; }[Reactive] public string Status { get; set; } = "Ready to record";
Это выглядит как магия: в скомпилированной сборке Fody уже расставил весь OnPropertyChanged-паттерн, а в исходниках остаётся чистый декларативный код. На этих свойствах затем строится вся реактивная логика через WhenAnyValue — подробнее в разделе про ReactiveUI ниже.
Прежде чем писать код: получаем ключи Azure Speech Service
Всё приложение вращается вокруг двух строк — ключа и региона Azure Speech Service. Без них распознаватель не запустится, а все примеры кода ниже превратятся в красивую, но бесполезную теорию. Поэтому — сначала дело, потом код.
Шаг 1. Аккаунт Azure
Если аккаунта ещё нет — azure.microsoft.com/free предлагает бесплатный уровень с $200 кредита на 30 дней. Карточка потребуется для верификации, но списаний в рамках бесплатного лимита не будет.
Шаг 2. Создаём ресурс Speech Service
-
Заходим на portal.azure.com
-
«Создать ресурс» → в поиске Marketplace пишем Speech → обязательно ставим галочку Azure services only — без неё поиск выдаёт сторонние SaaS-продукты, а не Microsoft-ресурс
-
Выбираем Speech от Microsoft (Azure Service) → нажимаем Create
-
Заполняем форму Create Speech Services:
-
Subscription — ваша подписка
-
Resource Group — создайте новую или выберите существующую
-
Region — выбирайте ближайший к пользователям регион (
westeurope,eastus,eastasiaи т.д.). Это и есть значение параметраRegionв настройках приложения -
Name — произвольное имя ресурса
-
Pricing tier — Free F0 для разработки: 5 часов распознавания и 5 часов перевода речи в месяц бесплатно. Standard S0 — для продакшена, оплата по факту использования
-
-
«Review + create» → Create → ждём деплоя (обычно меньше минуты) → нажимаем Go to resource
Шаг 3. Забираем ключ и регион
После деплоя на странице Overview ресурса прокручиваем вниз до секции Keys and endpoint:
-
Нажимаем Show Keys → копируем KEY 1 — это значение параметра
Key -
Location/Region — значение прямо под ключами, например
eastusилиwesteurope— это значение параметраRegion -
Endpoint — для справки:
https://eastus.api.cognitive.microsoft.com/
Внимание: ключ — это фактически пароль к вашему биллингу. Никогда не коммитьте его в git. В проекте AzioSpeech ключ шифруется через DPAPI сразу при сохранении в настройках — об этом отдельная глава ниже.
Ценник: что ждёт за пределами Free tier
|
Операция |
F0 (Free) |
S0 (Standard) |
|---|---|---|
|
Распознавание речи |
5 ч/мес |
~$1 за час аудио |
|
Перевод речи |
5 ч/мес |
~$2.50 за час аудио |
|
Speaker Diarization |
включена |
включена |
Цены актуальны на момент написания (май 2026), но Azure периодически пересматривает тарифы — сверяйтесь с официальным калькулятором.
Архитектура: кто кому говорит
Схема:
[Microphone] | ▼AudioCaptureService (NAudio WaveInEvent) | event AudioCaptured(byte[]) ├──────────────────────────┐ ▼ ▼TranscriptionService TranslationService(PushAudioInputStream (PushAudioInputStream → SpeechRecognizer / → TranslationRecognizer) ConversationTranscriber) | | ▼ ▼event OnTranscriptionUpdated event OnTranslationUpdated | | └──────────────────────────┘ | ▼ TranscriptionViewModel (ReactiveUI) | ▼ TranscriptionView (Avalonia)
Суть паттерна: AudioCaptureService — это единственный источник звука. TranscriptionService и TranslationService оба подписываются на его событие AudioCaptured и получают одинаковые байты независимо друг от друга. Подписка выглядит так:
// TranscriptionService — подписывается при старте записи_audioCapture.AudioCaptured += OnAudioCaptured;// TranslationService — подписывается при старте перевода_audioCaptureService.AudioCaptured += OnAudioCaptured;
Каждый сервис реализует свой обработчик OnAudioCaptured и пишет байты в свой собственный PushAudioInputStream. Никакой прямой связи между сервисами нет — они общаются только через события AudioCaptureService.
Важная деталь: AudioCapturedEventArgs отдаёт копию буфера, а не ссылку:
public byte[] GetAudioDataArray(){ var copy = new byte[_audioData.Length]; Array.Copy(_audioData, copy, _audioData.Length); return copy;}
NAudio переиспользует внутренний буфер при следующем вызове DataAvailable. Если бы мы отдавали ссылку, транскрипционный сервис мог бы прочитать уже перезаписанные данные. Классическая ошибка на собеседовании — не допускаем.
NAudio: захват звука как поток байт
NAudio — де-факто стандарт для работы с аудио в .NET. Нас интересует класс WaveInEvent, который нотифицирует о наличии новых данных через событие DataAvailable.
_waveIn = new WaveInEvent{ WaveFormat = new WaveFormat(settings.SampleRate, settings.BitsPerSample, settings.Channels), BufferMilliseconds = 50 // AudioConstants.BufferMilliseconds};_waveIn.DataAvailable += WaveIn_DataAvailable;_waveIn.StartRecording();
Три ключевых параметра для Azure Speech SDK:
-
Sample Rate: 16000 Гц — рекомендуемое значение. Технически SDK принимает и 8000 Гц, и 24000, и 48000 Гц, однако именно 16k — оптимальный баланс между качеством распознавания и объёмом передаваемых данных. Модели Speech Service обучены преимущественно на 16 kHz-аудио, поэтому другие частоты дадут либо избыточную полосу (48k), либо заметную потерю точности (8k вне телефонного контекста).
-
Bits Per Sample: 16-bit PCM — единственный официально поддерживаемый формат для raw PCM стриминга через
PushAudioInputStream. Azure Speech SDK не принимает 32-bit float (IeeeFloatWaveFormat): если попытаться передать такой поток — получите ошибку конфигурации. При необходимости конвертируйте заранее. (SDK поддерживает сжатые форматы — MP3, FLAC, Opus — черезAudioStreamFormat.GetCompressedFormat(), но это отдельный путь, не применимый к микрофонному захвату.) -
Channels: строго 1 (моно) для микрофонного ввода. Azure Speech SDK в стриминговом режиме рассчитан на моноканальный поток. Передача стерео-потока не определена стандартом: SDK может обработать только первый канал или вернуть ошибку. Конвертируйте в моно до отправки, а не надейтесь на «авось проглотит». (Исключение:
ConversationTranscriberподдерживает multi-channel audio до 8 каналов с диаризацией по каналам, но это специализированный сценарий для готовых многоканальных записей, а не живого микрофона.)
Обработчик DataAvailable:
private void WaveIn_DataAvailable(object? sender, WaveInEventArgs e){ var audioData = new byte[e.BytesRecorded]; Array.Copy(e.Buffer, audioData, e.BytesRecorded); Interlocked.Add(ref _totalBytesProcessed, e.BytesRecorded); AudioCaptured?.Invoke(this, new AudioCapturedEventArgs(audioData));}
e.BytesRecorded не равно e.Buffer.Length — в буфере может быть хвост старых данных. Это ещё одна классическая ловушка.
Отдельно про BufferMilliseconds = 50: если поставить слишком маленькое значение (например, 10 мс), будет огромная нагрузка на поток обработки. Слишком большое (500+ мс) — Azure начинает «жевать» начало фраз. 50 мс — работает.
Мост между NAudio и Azure: PushAudioInputStream
Главный клей всей конструкции — класс PushAudioInputStream. Это буфер, в который мы пишем PCM-байты из NAudio, а Azure Speech SDK читает из него в своём темпе.
Работа с ним делится на два этапа, связанных через поле класса _audioInputStream.
Этап 1 — инициализация, выполняется один раз при старте записи
var audioFormat = AudioStreamFormat.GetWaveFormatPCM( (uint)settings.SampleRate, (byte)settings.BitsPerSample, (byte)settings.Channels);_audioInputStream = AudioInputStream.CreatePushStream(audioFormat);var audioConfig = AudioConfig.FromStreamInput(_audioInputStream);// audioConfig передаётся в new SpeechRecognizer(speechConfig, audioConfig)
Здесь мы описываем формат будущих байтов, создаём пустую «трубу» (_audioInputStream) и подключаем к ней распознаватель. После этого SpeechRecognizer знает, откуда читать данные, и начинает ждать.
Этап 2 — подача данных, срабатывает каждые 50 мс
private void OnAudioCaptured(object? sender, AudioCapturedEventArgs e){ if (_isTranscribing && _audioInputStream != null && _transcriptionCts?.Token.IsCancellationRequested != true) { var audioData = e.GetAudioDataArray(); _audioInputStream.Write(audioData); // та же самая труба }}
_audioInputStream здесь — это то же самое поле класса, что было создано при старте. NAudio поймал очередные 50 мс звука, событие AudioCaptured сработало — мы кладём байты в трубу. SpeechRecognizer в фоновом потоке читает из неё в своём темпе и отправляет данные в облако.
Итого поток данных выглядит так:
[Старт записи]_audioInputStream = CreatePushStream(...)SpeechRecognizer подключается к _audioInputStream и ждёт[Каждые 50 мс, пока идёт запись]микрофон → OnAudioCaptured → _audioInputStream.Write(байты) ↓ SpeechRecognizer читает ↓ Azure, распознавание
Никаких MemoryStream, никаких промежуточных буферов — байты попадают в распознаватель с минимальной задержкой.
Базовая транскрипция: SpeechRecognizer
Первый режим — простое распознавание без разделения по спикерам.
var speechConfig = SpeechConfig.FromSubscription(settings.Key, settings.Region);speechConfig.SpeechRecognitionLanguage = settings.SpeechLanguage;speechConfig.SetProperty( PropertyId.SpeechServiceResponse_PostProcessingOption, "TrueText");speechConfig.SetProfanity( options.EnableProfanityFilter ? ProfanityOption.Masked : ProfanityOption.Raw);if (options.EnableWordLevelTimestamps) speechConfig.RequestWordLevelTimestamps();_recognizer = new SpeechRecognizer(speechConfig, audioConfig);_recognizer.Recognizing += OnRecognizing; // промежуточные результаты_recognizer.Recognized += OnRecognized; // финальный результат_recognizer.Canceled += OnCanceled;await _recognizer.StartContinuousRecognitionAsync();
Обратите внимание на PostProcessingOption = "TrueText" — это включает финальное форматирование текста: расставляет знаки препинания, убирает слова-паразиты, нормализует числа. Без этой опции транскрипт выглядит как стенограмма суда.
Два события — Recognizing и Recognized — отличаются принципиально:
-
Recognizing— промежуточное, «живое» распознавание. Идеально для отображения прогресса. -
Recognized— финальный результат после паузы в речи. Его и сохраняем.
private void OnRecognized(object? sender, SpeechRecognitionEventArgs e){ if (e.Result.Reason == ResultReason.RecognizedSpeech && !string.IsNullOrWhiteSpace(e.Result.Text)) { var segment = new TranscriptionSegment { Text = e.Result.Text, Timestamp = DateTime.Now, Duration = e.Result.Duration, }; _transcriptionDocument.Segments.Add(segment); OnTranscriptionUpdated?.Invoke(this, new TranscriptionSegmentEventArgs(segment)); }}
Диаризация спикеров: ConversationTranscriber
Вот здесь начинается настоящее веселье. Хотите знать, кто из говорящих что сказал? Нужен ConversationTranscriber.
// Включаем промежуточные результаты диаризацииspeechConfig.SetProperty( PropertyId.SpeechServiceResponse_DiarizeIntermediateResults, "true");_conversationTranscriber = new ConversationTranscriber(speechConfig, audioConfig);_conversationTranscriber.Transcribing += OnConversationTranscribing;_conversationTranscriber.Transcribed += OnConversationTranscribed;_conversationTranscriber.Canceled += OnConversationCanceled;await _conversationTranscriber.StartTranscribingAsync();
Ключевое отличие в событии Transcribed: у ConversationTranscriptionEventArgs есть поле SpeakerId.
private void OnConversationTranscribed(object? sender, ConversationTranscriptionEventArgs e){ if (e.Result.Reason == ResultReason.RecognizedSpeech && !string.IsNullOrWhiteSpace(e.Result.Text)) { var segment = new TranscriptionSegment { Text = e.Result.Text, Timestamp = DateTime.Now, Duration = e.Result.Duration, SpeakerId = e.Result.SpeakerId // <-- вот оно }; }}
SpeakerId — не имя и не номер микрофона. Это просто метка вида Guest-1, Guest-2, которую модель присваивает на основе акустических характеристик голоса. При коротком аудио модель может путаться или выдавать Unknown. Это нормально — диаризация работает лучше на записях от 30 секунд.
Важно для 2025–2026 годов: Произошло два разных deprecation:
Conversation Transcription Multichannel Diarization(retired март 2025) — вариант для многоканального аудио со специальным оборудованием, иSpeaker Recognition(retiring сентябрь 2025) — сервис регистрации голосовых профилей. То, что используется в проекте —ConversationTranscriberс моно-аудио без предрегистрации голосов — это отдельный механизм, он работает и сегодня.
Перевод в реальном времени: TranslationRecognizer
Параллельно с транскрипцией или независимо от неё работает перевод. Используется SpeechTranslationConfig:
var config = SpeechTranslationConfig.FromSubscription(settings.Key, settings.Region);config.SpeechRecognitionLanguage = sourceLanguage;config.AddTargetLanguage(targetLanguage);_audioStream = AudioInputStream.CreatePushStream(audioFormat);var audioConfig = AudioConfig.FromStreamInput(_audioStream);_recognizer = new TranslationRecognizer(config, audioConfig);_recognizer.Recognized += (s, e) =>{ if (e.Result.Reason == ResultReason.TranslatedSpeech && e.Result.Translations.ContainsKey(targetLanguage)) { var translatedText = e.Result.Translations[targetLanguage]; OnTranslationUpdated?.Invoke(this, new TranslationResultEventArgs(new TranslationResult { OriginalText = e.Result.Text, TranslatedText = translatedText, TargetLanguage = targetLanguage, Timestamp = DateTime.Now })); }};await _recognizer.StartContinuousRecognitionAsync();
Несколько нюансов:
-
AddTargetLanguage()принимает код языка перевода, а не BCP-47 код для распознавания. Например, для русского этоru, для итальянского —it. -
e.Result.Translations— словарь, и Azure Speech SDK действительно поддерживает несколько целевых языков одновременно через несколько вызововAddTargetLanguage(). Однако в данном проекте это ограничено намеренно:-
Язык источника (
SpeechRecognitionLanguage) жёстко зафиксирован — толькоen-US(см.SupportedLanguages.SpeechRecognitionLanguages). Говорить можно только по-английски. -
Целевой язык — один, выбирается пользователем из 9 поддерживаемых (ru, fr…).
-
-
Если
ResultReasonнеTranslatedSpeech— значит, облако не смогло перевести этот фрагмент. Чаще всего это тишина или шум.
Поддерживаемые языки перевода в проекте:
{ "es", "Spanish" }, { "fr", "French" }, { "de", "German" },{ "it", "Italian" }, { "pt", "Portuguese" }, { "ja", "Japanese" },{ "ko", "Korean" }, { "zh-Hans", "Chinese (Simplified)" }, { "ru", "Russian" }
Хранение API-ключа: DPAPI вместо plaintext
Хранить ключ Azure прямо в settings.json — идея примерно такая же хорошая, как держать пароль от рабочей почты на стикере на мониторе. В проекте это решено через Windows Data Protection API (DPAPI):
// Шифрование при сохранении (SettingsService)if (OperatingSystem.IsWindows() && !string.IsNullOrEmpty(settings.Key)){ var keyBytes = Encoding.UTF8.GetBytes(settings.Key); var encryptedBytes = ProtectedData.Protect( keyBytes, _entropy, // дополнительная entropy DataProtectionScope.CurrentUser); // только текущий пользователь settingsData.EncryptedKey = Convert.ToBase64String(encryptedBytes);}
DPAPI использует ключи, связанные с учётной записью Windows текущего пользователя. В обычных условиях расшифровать данные сможет только тот же пользователь на той же системе. DataProtectionScope.CurrentUser — правильный выбор для пользовательских настроек.
_entropy — дополнительные байты, используемые DPAPI при шифровании. Это не секретный ключ и не замена полноценному key management, но дополнительный параметр усложняет расшифровку данных вне контекста приложения.
Форматы экспорта: от plain text до SRT
После сессии записи пользователь может выбрать формат сохранения:
TXT — просто строки вида:
[14:32:05] Speaker Guest-1: Hello, how are you?[14:32:07] Speaker Guest-2: Fine, thanks.
JSON — структурированный объект TranscriptionDocument с полным набором метаданных.
SRT — формат субтитров с таймкодами и спикерными метками (на текущий момент все еще в разработке)
ReactiveUI в связке с событиями SDK
ViewModel подписывается на события сервисов через Observable.FromEventPattern. Это позволяет использовать всю мощь Rx: ObserveOn(RxApp.MainThreadScheduler) переключает обработку на UI-поток без ручных Dispatcher.Invoke.
Observable.FromEventPattern< EventHandler<TranscriptionSegmentEventArgs>, TranscriptionSegmentEventArgs>( h => _transcriptionService.OnTranscriptionUpdated += h, h => _transcriptionService.OnTranscriptionUpdated -= h) .Select(e => e.EventArgs) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe( HandleTranscriptionUpdated, ex => _logger.Log($"Error in transcription subscription: {ex.Message}")) .DisposeWith(disposables);
Здесь disposables — это CompositeDisposable, который ReactiveUI передаёт в блок this.WhenActivated(disposables => { ... }). Всё, что добавлено через .DisposeWith(disposables), автоматически отписывается когда View деактивируется (закрывается или скрывается). Это стандартный способ управления жизненным циклом подписок в ReactiveUI — вместо ручного хранения и вызова .Dispose() на каждой подписке:
// Так выглядит полный контекстthis.WhenActivated(disposables =>{ // Все подписки внутри этого блока живут ровно столько, // сколько активна View. При деактивации — автоматически отписываются. Observable.FromEventPattern(...) .Subscribe(HandleTranscriptionUpdated) .DisposeWith(disposables); // <-- добавляем в список для автоотписки});
WhenAnyValue — реактивные реакции на изменения свойств. Именно здесь связь с [Reactive]-свойствами становится ощутимой. WhenAnyValue создаёт IObservable<T>, который срабатывает каждый раз, когда меняется указанное свойство ViewModel. Никаких событий вручную, никаких флагов — только декларативная подписка:
// При включении перевода — автоматически выбрать язык по умолчаниюthis.WhenAnyValue(x => x.EnableTranslation) .Subscribe(enabled => { if (enabled && string.IsNullOrEmpty(SelectedTargetLanguage)) SelectedTargetLanguage = "it"; });// При смене языка или переключении перевода — обновить заголовок колонкиthis.WhenAnyValue(x => x.SelectedTargetLanguage, x => x.EnableTranslation) .Subscribe(tuple => { var (lang, enabled) = tuple; TranslationHeader = !enabled ? "Translation (Disabled)" : $"Translation ({SupportedLanguages.LanguageNames.GetValueOrDefault(lang, lang)})"; });
WhenAnyValue работает именно потому, что EnableTranslation, SelectedTargetLanguage и TranslationHeader помечены [Reactive] — без этого атрибута OnPropertyChanged не генерируется и observable ничего не испускает.
ReactiveCommand с canExecute — следующий шаг той же цепочки. StartCommand активен только когда IsRecording == false, и наоборот. Никакого ручного button.IsEnabled = ...:
var canStart = this.WhenAnyValue(x => x.IsRecording, isRecording => !isRecording);StartCommand = ReactiveCommand.CreateFromTask(StartRecordingAsync, canStart);
Отдельного внимания заслуживает .Skip(1), который встречается в некоторых подписках:
this.WhenAnyValue(x => x.IncludeTimestamps) .Skip(1) .Where(_ => !IsRecording) .Subscribe(_ => RefreshTranscriptDisplay());
WhenAnyValue по своей природе срабатывает сразу при подписке с текущим значением свойства — это не баг, а намеренное поведение: подписчик сразу получает актуальное состояние. Но в данном случае нам не нужен этот первый холостой вызов при инициализации ViewModel — RefreshTranscriptDisplay() на старте просто нечего обновлять. .Skip(1) отсекает именно этот первый вызов, оставляя только реакции на реальные изменения пользователем.
Что изменилось в Azure Speech SDK
Ребрендинг. Сервис теперь официально называется Azure AI Speech, однако NuGet-пакет по-прежнему живёт под старым именем Microsoft.CognitiveServices.Speech.
Два deprecation. Conversation Transcription Multichannel Audio Diarization — retired 28 марта 2025 года. Это был специализированный вариант для многоканального аудио, который требовал конкретного mic array устройства. К проекту AzioSpeech отношения не имеет. Speaker Recognition (voice profiles / voice signatures) — retiring 30 сентября 2025 года. Это отдельный сервис для идентификации конкретных людей по заранее зарегистрированным голосовым профилям. То, что используется в проекте — ConversationTranscriber с моно-аудио без предрегистрации голосов — не относится ни к одному из этих deprecation. Он возвращает generic метки Guest-1, Guest-2 на основе акустических характеристик прямо в потоке, и этот механизм продолжает работать.
Pricing. Free tier (F0) по-прежнему даёт 5 часов в месяц для распознавания и 5 часов перевода речи. Лимит в миллионах символов относится к отдельному сервису Cognitive Services Translator (текстовый перевод) — не путайте его со Speech Translation. В 2025 году Microsoft изменила модель ценообразования для ряда регионов — проверяйте актуальные цены в своём регионе перед деплоем.
Где зарыты грабли: практические советы
1. ConfigureAwait(false) — везде. В проекте это соблюдено последовательно. Без этого в Avalonia можно поймать дедлок при вызове async-кода из обработчиков событий.
2. SemaphoreSlim для защиты старта/стопа. AudioCaptureService использует SemaphoreSlim(1,1) вокруг операций старта и стопа захвата. Без этого двойной клик по кнопке “Start” может создать два экземпляра WaveInEvent — и ни один не будет остановлен корректно.
**3. NAudio MmException при старте записи чаще всего означает одно из трёх: микрофон не подключён, Windows заблокировала доступ в настройках приватности, или драйвер аудиоустройства вернул ошибку.
4. TrueText меняет содержимое. Если включён постпроцессинг TrueText, сервис может изменить слова (например, «four» → «4»). Для анализа дословной стенограммы стоит отключить.
Итоги
Паттерн NAudio WaveInEvent → PushAudioInputStream → Azure Speech SDK работает стабильно, хотя и требует аккуратного управления жизненным циклом стримов. Разделение на AudioCaptureService, TranscriptionService и TranslationService с шиной через события позволяет легко масштабировать: хотите добавить запись файла параллельно — подписывайтесь на AudioCaptured ещё одним подписчиком.
Код проекта доступен на GitHub, а сам проект выложен в MS Store: AzioSpeech Recognition and Translation.
P.S. На первом скриншоте использовался отрывок из рассказа Михаила Лермонтова «Фаталист» из сборника «Russian Short Stories from Pushkin to Buida» (издательство Penguin Classics) в переводе Роберта Чандлера.
ссылка на оригинал статьи https://habr.com/ru/articles/1034136/