惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Project Zero
Project Zero
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Scott Helme
Scott Helme
Know Your Adversary
Know Your Adversary
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
WordPress大学
WordPress大学
AWS News Blog
AWS News Blog
小众软件
小众软件
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Jina AI
Jina AI
AI
AI
美团技术团队
人人都是产品经理
人人都是产品经理
S
Secure Thoughts
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
V
Visual Studio Blog
宝玉的分享
宝玉的分享
Security Latest
Security Latest
P
Privacy & Cybersecurity Law Blog
C
Cisco Blogs
大猫的无限游戏
大猫的无限游戏
Google Online Security Blog
Google Online Security Blog
L
LINUX DO - 最新话题
罗磊的独立博客
Recent Announcements
Recent Announcements
H
Hacker News: Front Page
博客园 - 【当耐特】
K
Kaspersky official blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
SecWiki News
SecWiki News
Schneier on Security
Schneier on Security
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Apple Machine Learning Research
Apple Machine Learning Research
F
Full Disclosure
Google DeepMind News
Google DeepMind News
V
V2EX
博客园 - 聂微东
量子位
云风的 BLOG
云风的 BLOG
C
Check Point Blog
J
Java Code Geeks
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
W
WeLiveSecurity
Engineering at Meta
Engineering at Meta
V2EX - 技术
V2EX - 技术
Vercel News
Vercel News
L
LINUX DO - 热门话题
T
The Exploit Database - CXSecurity.com
L
Lohrmann on Cybersecurity
The GitHub Blog
The GitHub Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Голос в текст, текст в перевод: строим десктопное приложение для распознавания речи с Azure Speech SDK и NAudio
TheMysteriou · 2026-05-12 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение15 мин

Охват и читатели791

Туториал

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