Все обновления

Как сделать ии для собеседований

Технический разбор AI-ассистента для созвонов: захват системного аудио в Electron, стриминговый STT, LLM-подсказки и скрытие окна от шаринга экрана.

17 мая 2026 г.Sobesych Team
electronsttllmweb audiodesktopCapturersetContentProtection

Как сделать ии для собеседований

Дисклеймер про этику

Это разбор технологии, а не мануал «как обмануть HR». Понимать устройство таких штук полезно: разработчикам — чтобы знать пределы Web Audio и Electron API, рекрутерам — чтобы трезво смотреть на прокторинг, продактам — чтобы прикинуть, сколько работы стоит за «волшебной» цифрой в подписке. Тащить это на реальный собес — обман. Дальше только техника.


Что входит в MVP

Четыре куска:

  1. Транскрипция речи собеседника (system audio)
  2. Транскрипция речи пользователя (микрофон)
  3. AI-подсказки на основе обоих потоков
  4. Скрытие окна ассистента во время демонстрации экрана

Стек

Electron, React, Vite, Anthropic SDK, какой-нибудь STT-провайдер. Electron — потому что для системного аудио нужен desktopCapturer, а для скрытия окна — setContentProtection. В браузере этого нет. Vite — для скорости разработки. Claude — для подсказок. STT — Deepgram или OpenAI Realtime; почему именно стриминг, объясню ниже.


Часть 1. Транскрипция собеседника (system audio)

Самая муторная часть проекта. С микрофоном проблем нет, его умеют все. А вот «услышать» голос человека на том конце Zoom — это нативный API, и в каждой ОС он работает по-своему.

В Electron есть три варианта, и в проде нужны все три как fallback-цепочка:

1. getDisplayMedia({ audio: true })        ← стандартный Web API
        ↓ не вернул аудио?
2. desktopCapturer + getUserMedia          ← Electron-only
   с chromeMediaSource: 'desktop'
        ↓ не сработало?
3. Legacy chromeMediaSource без sourceId   ← для старых версий

Шаг 1: пробуем getDisplayMedia

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,            // обязательно, иначе аудио не дадут
  audio: true,
})

const audioTracks = stream.getAudioTracks()
if (audioTracks.length > 0) {
  // повезло: пользователь чекнул "Share audio" в диалоге
}

Нюанс: на macOS до Sonoma этот путь системный звук не отдаёт вообще. На Windows работает, но только если юзер сам выбрал «Share audio» в диалоге.

Шаг 2: через desktopCapturer

Если первый путь без аудио — лезем через Electron API. В preload-скрипте экспортируем:

contextBridge.exposeInMainWorld('electron', {
  media: {
    getSystemAudioSourceId: async () => {
      const sources = await desktopCapturer.getSources({ types: ['screen'] })
      return sources[0]?.id ?? null
    },
  },
})

В рендерере используем нестандартные constraints:

const sourceId = await window.electron.media.getSystemAudioSourceId()

const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    mandatory: {
      chromeMediaSource: 'desktop',
      chromeMediaSourceId: sourceId,
    },
  },
  video: false,
})

chromeMediaSource: 'desktop' — это недокументированные хром-флаги, доставшиеся Electron'у по наследству. Работают, IDE подсветит красным — забейте.

Шаг 3 (legacy)

Совсем древний вариант, держу как safety net:

const stream = await navigator.mediaDevices.getUserMedia({
  audio: { mandatory: { chromeMediaSource: 'desktop' } },
  video: false,
})

macOS Sonoma+ и ScreenCaptureKit

С macOS 14 Apple наконец открыли ScreenCaptureKit — нативный API захвата экрана со звуком. Electron 28+ поддерживает его через тот же desktopCapturer. На свежих маках всё работает из коробки, без плясок. На старых — fallback'и выше.


Часть 2. Транскрипция пользователя (микрофон)

После предыдущей секции — детский сад:

const micStream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
  },
  video: false,
})

Три флага в audio обязательны. Без echoCancellation ваш собственный голос пролезет в системный поток (через колонки → микрофон), и STT начнёт галлюцинировать.

Теперь у нас два независимых потока: systemStream и micStream. Отправляем оба в STT по отдельности — так можно понимать, кто что сказал, не возясь с диаризацией.


Часть 3. STT — Speech-to-Text

Главный вопрос здесь: батч или стриминг?

Батч (Whisper API). Пишете в MediaRecorder, копите 10 секунд, отправляете chunk, ждёте ответ. Дёшево, точно, задержка 5–10 секунд. На созвоне это смерть UX.

Стриминг (Deepgram, AssemblyAI, OpenAI Realtime API). Открываете WebSocket, шлёте PCM-чанки, получаете промежуточные транскрипты с задержкой ~300мс. Дороже в 2–3 раза, но иначе ассистент будет вечно догонять.

Минимальный псевдокод для Deepgram:

const ws = new WebSocket(
  'wss://api.deepgram.com/v1/listen?model=nova-2&interim_results=true',
  ['token', DEEPGRAM_API_KEY]
)

const audioCtx = new AudioContext({ sampleRate: 16000 })
const source = audioCtx.createMediaStreamSource(systemStream)
const processor = audioCtx.createScriptProcessor(4096, 1, 1)

source.connect(processor)
processor.connect(audioCtx.destination)

processor.onaudioprocess = (e) => {
  const pcm = e.inputBuffer.getChannelData(0)
  ws.send(convertFloat32ToInt16(pcm))
}

ws.onmessage = (msg) => {
  const { channel } = JSON.parse(msg.data)
  const text = channel.alternatives[0].transcript
  if (text) onTranscript({ speaker: 'system', text, isFinal: channel.is_final })
}

То же самое отдельным сокетом для micStream, но с speaker: 'user'.

Пара слов про ScriptProcessorNode. Он deprecated, в проде надо переезжать на AudioWorkletNode. Но для MVP ScriptProcessor проще и работает.


Часть 4. AI-подсказки

К этому моменту у нас есть поток событий:

{ speaker: 'system', text: 'Расскажи про event loop', timestamp: 12.3 }
{ speaker: 'user',   text: 'Так, event loop это...', timestamp: 13.1 }
{ speaker: 'system', text: '...в Node.js',           timestamp: 13.4 }

Задача LLM — подсказать ответ на последний вопрос собеседника с учётом того, что юзер уже что-то начал говорить.

Когда дёргать LLM?

Не на каждый токен (разоримся на API) и не по таймеру (отстанем от ритма). Правильный триггер — завершение реплики собеседника: длинная пауза плюс is_final: true от STT.

let lastSystemText = ''
let pauseTimer

onTranscript(({ speaker, text, isFinal }) => {
  if (speaker !== 'system' || !isFinal) return

  lastSystemText += ' ' + text
  clearTimeout(pauseTimer)
  pauseTimer = setTimeout(() => {
    askLLM(lastSystemText, recentContext)
    lastSystemText = ''
  }, 800) // 800мс паузы = реплика закончена
})

Контекст для LLM

Слать всю транскрипцию не надо — дорого и шумно. Достаточно последних ~30 секунд плюс системного промпта:

SYSTEM: Ты помогаешь инженеру на собеседовании на Senior Backend (Node.js).
Тебе дают последний вопрос интервьюера и контекст разговора.
Дай ОЧЕНЬ короткую подсказку: главный поинт в одну строку + 2-3 буллета "о чём упомянуть".
Полный ответ не пиши — инженер сам говорит. Твоя задача — не дать ему забыть ключевое.

CONTEXT (последние 30 сек):
[интервьюер]: А как Node.js обрабатывает асинхронные операции?
[инженер]: Ну, через event loop...
[интервьюер]: Окей, расскажи про event loop в Node.js

ANSWER:

И стримим ответ Claude прямо в окно:

const stream = await anthropic.messages.stream({
  model: 'claude-opus-4-7',
  max_tokens: 200,
  system: SYSTEM_PROMPT,
  messages: [{ role: 'user', content: buildContext(transcript) }],
})

for await (const event of stream) {
  if (event.type === 'content_block_delta') {
    window.postMessage({ type: 'hint-token', text: event.delta.text })
  }
}

Почему max_tokens: 200: длинная подсказка — это провал. Инженер не успеет её прочитать, пока говорит. Лучше коротко и по делу.


Часть 5. Скрытие окна во время демонстрации экрана

Самое смешное в этой истории — то, что весь предыдущий пайплайн бесполезен, если интервьюер видит окно ассистента у вас на экране. А Zoom, как и любой нормальный мессенджер, при шаринге показывает всё, что на дисплее.

Хорошая новость: на уровне ОС есть способ сказать «вот это окно в захват не включать». Плохая новость: работает он не везде одинаково.

Одна строчка

В Electron при создании окна:

const win = new BrowserWindow({
  width: 400,
  height: 300,
  transparent: true,
  frame: false,
  alwaysOnTop: true,
  // ...
})

win.setContentProtection(true)

Всё. Этот вызов под капотом делает разные вещи в зависимости от ОС:

  • macOS — выставляет окну NSWindowSharingNone. Стандартные пути захвата (CGWindowListCreateImage, ScreenCaptureKit) это уважают. Окно невидимо в Zoom, Teams, Meet, Discord — везде, где захват идёт через нативные API.
  • Windows 10 build 2004+ — зовёт SetWindowDisplayAffinity с флагом WDA_EXCLUDEFROMCAPTURE. Окно полностью пропадает из скриншотов и записи.
  • Windows старее 2004 — fallback на WDA_MONITOR. Окно превращается в чёрный прямоугольник в захвате. Менее красиво, но факт его существования всё ещё палится.
  • Linux — не поддерживается, на X11 такого механизма нет в принципе.

Что ещё нужно настроить у окна

Одного setContentProtection мало, чтобы окно было удобным оверлеем. Минимальный набор флагов:

const win = new BrowserWindow({
  width: 420,
  height: 240,
  transparent: true,         // прозрачный фон, рисуем только нужное
  frame: false,              // никакой системной рамки
  alwaysOnTop: true,         // поверх Zoom и всего остального
  skipTaskbar: true,         // не светимся в панели задач / доке
  resizable: false,
  hasShadow: false,
  focusable: false,          // не отбираем фокус у Zoom при клике
  webPreferences: { preload: path.join(__dirname, 'preload.js') },
})

win.setContentProtection(true)
win.setAlwaysOnTop(true, 'screen-saver') // поверх даже полноэкранных
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreenMacOS: true })

Ключевой момент — focusable: false плюс setAlwaysOnTop(..., 'screen-saver'). Без них клик по подсказке может увести фокус с окна Zoom, и некоторые платформы (Hackerrank, Codility) это детектят как «переключился на другое приложение». А visibleOnFullScreenMacOS: true нужен, чтобы оверлей не исчезал, когда Zoom уходит в полноэкранный режим — типичный сценарий шаринга на маке.

Грабли с hide() / show()

На Windows есть известный баг: если вызвать win.hide(), а потом win.show(), поведение setContentProtection ломается — окно начинает показываться в захвате чёрным прямоугольником вместо невидимого. Лечится переустановкой флага после показа:

function safeShow(win) {
  win.showInactive()              // showInactive, не show — чтобы не воровать фокус
  win.setContentProtection(true)  // переустанавливаем после show
}

Я в итоге пришёл к тому, что окно вообще никогда не hide() — только меняю позицию за пределы экрана или прозрачность. Меньше шансов словить регрессию после очередного обновления Electron (а они там по этой теме случаются регулярно).

Как проверить, что реально работает

Дев-цикл простой: открываете на маке встроенный Screenshot (Cmd+Shift+5) или на винде Snipping Tool, запускаете запись экрана, смотрите получившееся видео. Если окна там нет — работает. Если есть или чёрный прямоугольник — фиксите.

Дополнительно тестьте именно в той платформе, для которой делаете — Zoom, Meet, Teams. Иногда они используют не системный захват, а свои хаки (особенно в браузерных версиях), и поведение может отличаться. setContentProtection покрывает большинство кейсов, но 100% гарантии не даёт никто.


Грабли, на которые я наступил

Эхо от своих же колонок. Если юзер слушает Zoom через динамики, его голос лезет в system stream через микрофон. Лечится наушниками либо AEC на уровне аудио-пайплайна (это сложно).

STT галлюцинирует на тишине. Deepgram иногда выдаёт «thank you» или «bye» на пустом потоке. Решается VAD-фильтром перед отправкой в WS.

LLM не успевает. Если интервьюер говорит быстро и слитно, пауза в 800мс просто не наступает. Помогает второй триггер: «прошло 5 секунд от последнего знака вопроса».

Цена. Realtime STT + LLM-стрим — это примерно $0.5 в час разговора. Для пет-проекта норм, для продукта думайте про кэш промптов и переезд на self-hosted Whisper.

Permission hell на macOS. Доступ к микрофону, к экрану, к Accessibility — три разных диалога, и Electron-приложение должно быть подписано как надо, иначе часть прав просто не дают. На неподписанной dev-сборке кое-что вообще не запросить.

Фокус и платформы для собесов. Hackerrank, Codility, Karat и подобные часто детектят потерю фокуса главного окна. focusable: false на оверлее обязателен. И отдельно потестьте всё в полноэкранном режиме Zoom, там флаги ведут себя иначе.


Что получилось

Минимальный ассистент для созвонов — это:

  1. system audio через desktopCapturer с fallback на getDisplayMedia
  2. mic audio через обычный getUserMedia с включённым AEC
  3. два независимых WebSocket'а в стриминговый STT
  4. LLM-стрим, который дёргается на паузе в речи собеседника
  5. setContentProtection(true) плюс правильный набор флагов окна, чтобы оверлей не попадал в захват экрана

Пять компонентов, склеенных в один pipeline. Дальше — детали и оптимизация: VAD, кэширование промптов, переезд на AudioWorklet, нормальный AEC.