Как сделать ии для собеседований
Дисклеймер про этику
Это разбор технологии, а не мануал «как обмануть HR». Понимать устройство таких штук полезно: разработчикам — чтобы знать пределы Web Audio и Electron API, рекрутерам — чтобы трезво смотреть на прокторинг, продактам — чтобы прикинуть, сколько работы стоит за «волшебной» цифрой в подписке. Тащить это на реальный собес — обман. Дальше только техника.
Что входит в MVP
Четыре куска:
- Транскрипция речи собеседника (system audio)
- Транскрипция речи пользователя (микрофон)
- AI-подсказки на основе обоих потоков
- Скрытие окна ассистента во время демонстрации экрана
Стек
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, там флаги ведут себя иначе.
Что получилось
Минимальный ассистент для созвонов — это:
- system audio через
desktopCapturerс fallback наgetDisplayMedia - mic audio через обычный
getUserMediaс включённым AEC - два независимых WebSocket'а в стриминговый STT
- LLM-стрим, который дёргается на паузе в речи собеседника
setContentProtection(true)плюс правильный набор флагов окна, чтобы оверлей не попадал в захват экрана
Пять компонентов, склеенных в один pipeline. Дальше — детали и оптимизация: VAD, кэширование промптов, переезд на AudioWorklet, нормальный AEC.