
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
- Заходим на portal.azure.com
- «Создать ресурс» → в поиске Marketplace пишем Speech → обязательно ставим галочку Azure services only — без неё поиск выдаёт сторонние SaaS-продукты, а не Microsoft-ресурс

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

Azure_Speech_2.png
- Заполняем форму Create Speech Services:
- Subscription — ваша подписка
- Resource Group — создайте новую или выберите существующую
- Region — выбирайте ближайший к пользователям регион (westeurope, eastus, eastasia и т.д.). Это и есть значение параметра Region в настройках приложения
- Name — произвольное имя ресурса
- Pricing tier — Free F0 для разработки: 5 часов распознавания и 5 часов перевода речи в месяц бесплатно. Standard S0 — для продакшена, оплата по факту использования

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

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();
Несколько нюансов:
- 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) в переводе Роберта Чандлера.-Источник