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) в переводе Роберта Чандлера.