Голос в текст, текст в перевод: строим десктопное приложение для распознавания речи с Azure Speech SDK и NAudio

от автора

Azio-Speech.png

Azio-Speech.png

Предисловие: зачем вообще это нужно

Представьте сценарий: вы ведёте встречу на английском с иностранными коллегами, и кто-то хочет получить стенограмму с привязкой к спикерам — и сразу переведённую, к примеру, на итальянский. Или вы транскрибируете интервью для статьи. Или просто хочется поиграться с 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

  1. Заходим на portal.azure.com

  2. «Создать ресурс» → в поиске Marketplace пишем Speech → обязательно ставим галочку Azure services only — без неё поиск выдаёт сторонние SaaS-продукты, а не Microsoft-ресурс

Azure_Speech.png

Azure_Speech.png
  1. Выбираем Speech от Microsoft (Azure Service) → нажимаем Create

Azure_Speech_2.png

Azure_Speech_2.png
  1. Заполняем форму Create Speech Services:

    • Subscription — ваша подписка

    • Resource Group — создайте новую или выберите существующую

    • Region — выбирайте ближайший к пользователям регион (westeurope, eastus, eastasia и т.д.). Это и есть значение параметра Region в настройках приложения

    • Name — произвольное имя ресурса

    • Pricing tierFree F0 для разработки: 5 часов распознавания и 5 часов перевода речи в месяц бесплатно. Standard S0 — для продакшена, оплата по факту использования

Azure_Speech_3.png

Azure_Speech_3.png
  1. «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/

Azure_Speech_4.png

Azure_Speech_4.png
Azio_Speech_2.png

Azio_Speech_2.png

Внимание: ключ — это фактически пароль к вашему биллингу. Никогда не коммитьте его в 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 (IeeeFloat WaveFormat): если попытаться передать такой поток — получите ошибку конфигурации. При необходимости конвертируйте заранее. (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();

Несколько нюансов:

  1. AddTargetLanguage() принимает код языка перевода, а не BCP-47 код для распознавания. Например, для русского это ru, для итальянского — it.

  2. e.Result.Translations — словарь, и Azure Speech SDK действительно поддерживает несколько целевых языков одновременно через несколько вызовов AddTargetLanguage(). Однако в данном проекте это ограничено намеренно:

    • Язык источника (SpeechRecognitionLanguage) жёстко зафиксирован — только en-US (см. SupportedLanguages.SpeechRecognitionLanguages). Говорить можно только по-английски.

    • Целевой язык — один, выбирается пользователем из 9 поддерживаемых (ru, fr…).

  3. Если 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/