Дисклеймер: Автор признаёт, что приведённый ниже код может быть далёк от идеала. Его цель — показать, как легко создать подобную утилиту своими руками. Также автор не исключает, что при решении отдельных задач с кодом обращался за помощью к нейросетям.

Я — .NET-разработчик, и уже некоторое время прохожу собеседования. Иногда на них задают вопросы про такие «пыльные уголки» .NET, что если я и читал про них, то лишь в самом начале своего пути, листая книги Рихтера, Троелсена и Шилдта.

А ещё встречаются откровенно странные задачи, далёкие от реальной работы.

Зачем всё это? Очевидно, чтобы отсечь новичков из онлайн-курсов и тех, кто «вкатился» в IT без должной подготовки.

Ну а раз на собеседовании всё изначально настроено так сказать «враждебно», и интервьюер подозревает, что каждый мухлюет… почему бы действительно не начать мухлевать?

Сначала я полез в интернет в поисках готового решения и наткнулся на WiseWhisper.

Эта утилита создаёт полупрозрачный оверлей, слушает звуки с ПК и подсказывает ответы в реальном времени. Звучит идеально… но стоит $29 в месяц. Довольно дорого, согласитесь, а бесплатная версия предлагает всего 10 подсказок.

Тогда я подумал: а почему бы не написать своё решение?

Так началось моё R&D.

R&D

Поковыряв WiseWhisper, я понял, что программа не делает ничего сверхъестественного:

  1. Слушает звуки с ПК.

  2. Переводит их в текст.

  3. Отправляет текст в нейросеть и отображает ответ в оверлее.

Задачи вроде бы простые, но прежде чем их решать, я хотел убедиться, насколько легко скрыть окно программы от лишних глаз.

Спросил первым делом у ChatGPT — и он предоставил простой, рабочий вариант, который я тут же протестировал на WPF-приложении:

private const uint WDA_EXCLUDEFROMCAPTURE = 0x11;

[DllImport("user32.dll")]
private static extern bool SetWindowDisplayAffinity(IntPtr hWnd, uint dwAffinity);

protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var hwnd = new WindowInteropHelper(this).Handle;
SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE);
}

Как видите, в OBS не видно окна
Как видите, в OBS не видно окна

Это, так сказать, вдохновило меня, и я принялся искать решения для следующих задач.

Работа со звуком

Для работы со звуком я выбрал NAudio.

Логика была довольно простой:

  1. Создаём WasapiLoopbackCapture.

  2. Подписываемся на событие DataAvailable, в котором записываем данные через WaveFileWriter в MemoryStream.

  3. Если звука не было в течение N времени, создаём файл .wav и передаём его дальше.

Примерно так выглядела вся инициализация:

_capture = new WasapiLoopbackCapture();

audioBuffer = new MemoryStream();
audioBufferWriter = new WaveFileWriter(_audioBuffer,
WaveFormat.CreateIeeeFloatWaveFormat(_capture.WaveFormat.SampleRate, capture.WaveFormat.Channels));
capture.StartRecording();

Проблема была в том, что винда отсылает "звук", даже когда его нету и поэтому мне пришлось написать вот такой метод для фильтрации:

private bool IsSilence(WaveInEventArgs e, float threshold = 0.0005f) { int samplesCount = e.BytesRecorded / 4; var samples = new float[samplesCount]; Buffer.BlockCopy(e.Buffer, 0, samples, 0, e.BytesRecorded); foreach (var sample in samples) { if (Math.Abs(sample) > threshold) return false; } return true; }

Речь в текст

Следующим этапом было преобразование речи в текст.

На моём ПК уже был установлен Whisper, который умеет это делать. Первым делом я попытался интегрировать его в программу через командную строку, но это оказалось не самым оптимальным вариантом: Whisper каждый раз при запросе подгружал модель, и процесс был медленным.

Решением стала библиотека Whisper.net, которая позволяет удобно и быстро взаимодействовать с моделью.

Для работы необходимо скачать модель и указать путь к ней при создании через WhisperFactory. Я использовал модель ggml-small.bin.

Примерно так выглядел весь код:

whisperProcessor = WhisperFactory.FromPath(modelPatch).CreateBuilder().WithLanguage("auto").Build(); var stringBuilder = new StringBuilder(); await using var fileStream = File.OpenRead(filePath); await foreach (var segment in whisperProcessor.ProcessAsync(fileStream)) { stringBuilder.Append(segment.Text); } return stringBuilder.ToString();

Правда, на этом этапе я столкнулся с проблемой: Whisper не поддерживает 32-битный звук. Поэтому на этапе записи аудио пришлось добавить конвертацию из 32 бит в 16 бит, примерно так:

private byte[] To16KSample() { using var memoryStream = new MemoryStream(_audioBuffer.ToArray()); using var reader = new WaveFileReader(memoryStream); using var resampler = new MediaFoundationResampler(reader, new WaveFormat(16000, 16, 1)) { ResamplerQuality = 60 }; using var ms16k = new MemoryStream(); WaveFileWriter.WriteWavFileToStream(ms16k, resampler); return ms16k.ToArray(); }

Отправка текста в нейросеть

И, наконец, заключительный этап — отправить текст в нейросеть и получить ответ.

Для этого я воспользовался Ollama и её удобной библиотекой OllamaSharp:

public void Start()
{
var http = new HttpClient
{
BaseAddress = new Uri("http://localhost:11434")
};

http.DefaultRequestHeaders.Add("x-api-key", "KEY");
client = new OllamaApiClient(http,"deepseek-v3.1:671b-cloud");
chat = new Chat(_client);
Task.Run(async () =>
{
await foreach (var in chat.SendAsync("Помоги мне с собеседованием. Отвечай коротко на вопросы"))
{
}
}).GetAwaiter().GetResult();
}

public IAsyncEnumerable<string?> AskAsync(string question)
{
return _chat.SendAsync(question);
}

Ключ можно легко и бесплатно получить на их официально сайте.

В итоге все выглядит как-то так:

SetWindowDisplayAffinity закомитил, чтобы захват был
SetWindowDisplayAffinity закомитил, чтобы захват был

Весь код проекта я пока не прикладываю — прежде хочу его немного «причесать» и сделать более аккуратным.