A real-time desktop interview assistant that captures system audio, transcribes it, detects interview questions, and streams AI-generated answers to a protected floating overlay β on a single free API key.
WingMan captures all system audio, which in a meeting means everyone in it. Recording or transcribing a conversation without the consent of every participant is illegal in many jurisdictions, and using an AI assistant in a live interview breaches the terms of most interview and meeting platforms β and, usually, the trust of the person on the other end.
It is published as a study of a hard real-time problem: sub-second system audio β VAD β transcription β question detection β streamed LLM answer, on a free API tier. Use it to prepare for interviews, to rehearse against your own recordings, as a live captioning and note-taking aid, or as a reference for building low-latency audio pipelines.
You are solely responsible for how you use it. Know the law where you live, get consent, and read SECURITY.md for what the app does with your audio and where your data goes.
- Runs on one free key β transcription (
whisper-large-v3-turbo) and answers both use Groq. The free tier normally covers an entire interview at no cost - Real-time audio capture β WASAPI loopback on Windows via
pyaudiowpatchcaptures system audio without microphone access (macOS/Linux viasounddevicemonitor / BlackHole) - Pay for speech, not silence β dependency-free local VAD (
python/vad.py) segments the stream and uploads only speech, so pauses cost nothing. Minimum is measured in voiced frames so keyboard clicks never reach a paid endpoint - Two transcription engines β
groq(default, batch, speech-only, free) ordeepgram(opt-in streamingnova-2with interim results, ~9x cost, billed on connection time) - Smart question detection β heuristic prefix/keyword pipeline + cheap Groq classifier. Direct questions (
tell me...,how...?) bypass the LLM; ambiguous utterances are classified on a background thread - Multilingual interviews β non-English sessions automatically route utterances with no English signal to the classifier (bounded by
MIN/MAX_CLASSIFIER_WORDS) so imperative prompts likecuΓ©ntame sobre...are not dropped - Streamed AI answers β token-by-token Groq chat completions with
openai/gpt-oss-120b(and live fallback). Answers are grounded in resume + extra context and streamed to the overlay and toPOST /answer/manual - Runtime model resolution β
LLMClient.resolve_models()lists what the key can actually reach at session start and falls back throughANSWER_MODEL_PREFERENCESwhen a saved ID was retired. Dashboard picker is populated fromPOST /models - Resilient rate-limit handling β
LLMClient._create()retries 429/408/5xx withRetry-After+ capped backoff (MAX_RETRY_WAIT_SECONDS = 10s), then falls back to a sibling model. Streamed chunks are never retried once tokens have been shown - ~800 ms end-to-first-token β VAD hangover + Whisper round-trip + classifier +
gpt-oss-120bTTFT (measured at wall-clock speed on recorded speech) - Protected overlay β floating, draggable, resizable, transparent overlay with
setContentProtection(true)(WDA_EXCLUDEFROMCAPTUREon Windows) β invisible to Teams / Zoom / Meet / OBS screen-share. Re-applied onshow/restore/focus/maximize/did-finish-load - Resume grounding β upload a PDF (PyMuPDF extraction) or paste resume text + job description / panel context
- Live cost meter β
python/usage.pytracks speech seconds (exact VAD) and LLM tokens (realchunk.x_groq.usagecounts). Shown in dashboard and viaGET /usage/usageSSE events - Secure key storage β both keys encrypted via Electron
safeStorage(OS keychain) intosettings.jsonunderuserData. OnlyapiKeyStoredbooleans reach the renderer - Session history β optionally persist Q&A exchanges as JSON under
userData/history/for post-interview review (GET /history,history:open-folder) - Global shortcuts β toggle, minimize, and focus the overlay without leaving the interview window
Three runtimes, two transports.
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Electron Main Process (src/main.ts) β
β ββ Window Manager (dashboard + overlay) β
β ββ Secure Store (safeStorage API keys) β
β ββ Python Server Manager (sidecar lifecycle) β
β ββ IPC Handlers (assertTrustedSender) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β React Renderer (Vite + Tailwind, src/App.tsx) β
β ββ Dashboard: setup, history, settings β
β ββ Overlay: transcript, answers, manual input β
β One bundle loaded twice (#/dashboard / #/overlay)β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Python Sidecar (Flask + SSE, python/server.py) β
β ββ WASAPI Loopback Audio Capture β
β ββ Voice Activity Detection (silence is free) β
β ββ Transcription (Groq Whisper β Deepgram) β
β ββ Question Detection (heuristic + LLM) β
β ββ Answer Streaming (Groq chat completions) β
β ββ Usage / cost metering β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Plane | Path | Auth |
|---|---|---|
| Control | renderer β window.wingman.* β ipcMain.handle β PythonServerManager.request() β session start/stop, settings, keys, overlay geometry, POST /models |
Electron IPC + SecureStore (keys never cross to renderer) |
| Data | renderer β http://127.0.0.1:<port> directly β SSE /transcript/stream & /answer/stream, POST /answer/manual, /resume/upload, /history |
X-Wingman-Token header (fetch) or ?token= query (EventSource); require_server_token accepts both |
Main hands the renderer serverPort + serverToken inside AppState; the sidecar binds an ephemeral port on 127.0.0.1 and prints PORT:<n> on stdout.
WASAPI loopback (16 kHz mono int16) β SessionManager.audio_queue
β Transcriber (GroqTranscriber | DeepgramTranscriber)
β _on_transcript β _publish_transcript
β accumulates segments; on QUESTION_SETTLE_SECONDS gap flushes:
DIRECT_QUESTION_PREFIXES β enqueue immediately
ambiguous β classifier thread (cheap model)
non-question β drop
β answer_queue β _stream_answer_worker
β fans tokens to every SSE subscriber + private queue for /answer/manual
- GroqTranscriber (
python/vad.py+python/transcriber.py): VAD-gated batch uploads. Two concurrent requests with_emit_in_orderre-serialization;on_activityfires at speech onset so the overlay showstranscribingwithout waiting for Whisper. No interims. - DeepgramTranscriber: streaming WebSocket with interim results.
- Concurrency invariant: every worker captures
runtime_id/stop_event/llmat start and re-checksself.runtime_id != runtime_idbefore emitting.start_session()bumpsruntime_idso a stopped session never leaks tokens into the next one.
PythonServerManager (src/pythonServer.ts) generates a 32-byte hex token, passes WINGMAN_SERVER_TOKEN + WINGMAN_HISTORY_DIR, then waits for PORT:<n> before polling /health. Spawn order: WINGMAN_PYTHON_BIN β packaged resourcesPath/python/wingman-server/wingman-server.exe β .venv/Scripts/python.exe (win) / python3. Packaged exe is console=False, so a Windows netstat -ano PIDβport fallback is used. Unexpected exit triggers scheduleServerRestart() with a 1.2 s retry loop.
The default engine uploads only detected speech, so a long interview with ordinary pauses is billed for a fraction of its wall-clock length.
| Engine | Billed on | 1 hr interview (~25 min speech) |
|---|---|---|
Groq Whisper whisper-large-v3-turbo (default) |
speech only (record_audio exact VAD) |
~$0.017, or $0 on the free tier |
Deepgram nova-2 streaming |
connection time (set_stream_seconds wall clock) |
~$0.35 |
Answers add roughly $0.001β0.01 per interview depending on model. The dashboard CostMeter shows the running total; GET /usage on the local backend returns the same UsageSnapshot (python/usage.py).
| Layer | Tech |
|---|---|
| Desktop | Electron 41, Vite 5, React 18, React Router 6, Tailwind CSS 3 |
| Backend | Python 3.10+, Flask 3, Groq SDK, PyMuPDF, sounddevice / pyaudiowpatch, websocket-client, numpy |
| Packaging | electron-builder (NSIS), PyInstaller (python/wingman-server.spec) |
| Platform | Download | Audio capture | Overlay hidden from capture |
|---|---|---|---|
| Windows 10/11 | .exe installer |
WASAPI loopback via pyaudiowpatch β no extra setup |
Yes β WDA_EXCLUDEFROMCAPTURE |
| macOS 11+ | .dmg (arm64 and x64) |
Needs a virtual device β BlackHole plus a Multi-Output Device | Yes β setContentProtection |
| Linux | .AppImage / .deb (x64) |
PulseAudio/PipeWire .monitor source via sounddevice |
X11 only, best-effort; not under Wayland |
All three are built and published by CI. Windows is the only one routinely tested in a real interview β macOS and Linux builds are produced from the same source and pass the same gate, but they get far less use, so treat them as beta and please report what breaks.
Every build is unsigned. See Installation for the SmartScreen and Gatekeeper prompts that follow from that.
On Windows, health reports capture_warning on builds older than 10.0.22621
(python/server.py:health).
- Node.js 18+ and Python 3.10+
- A Groq API key β free tier, no credit
card required, and this is the only key you need. Sign in with Google or
GitHub at console.groq.com/keys, click
Create API Key, and copy the
gsk_β¦value. It is shown once - A Deepgram key β optional, only if you switch the transcription engine to Deepgram in the dashboard (~9x the cost)
Grab the file for your platform from the Releases page.
Windows β run WingMan-<version>-setup.exe. SmartScreen will show
"Windows protected your PC"; click More info β Run anyway.
macOS β open WingMan-<version>-arm64.dmg (Apple silicon) or -x64.dmg
(Intel) and drag the app to Applications. Gatekeeper will refuse it on first
launch, so right-click the app and choose Open, or run:
xattr -dr com.apple.quarantine /Applications/WingMan.appThen install BlackHole and create a Multi-Output Device, or there is no system audio to capture.
Linux β chmod +x WingMan-<version>-x64.AppImage && ./WingMan-<version>-x64.AppImage,
or sudo dpkg -i WingMan-<version>-x64.deb. You need a PulseAudio/PipeWire
monitor source enabled.
Every build is unsigned β there is no Authenticode certificate or Apple Developer ID behind this project. Verify the
SHA256SUMS-*.txtattached to the release, or build from source, if you would rather not take that on trust.
Windows Defender / antivirus may flag the bundled
wingman-server.exe. This is a false positive from PyInstaller packaging. Add an exclusion for the WingMan install directory if prompted. Test packaged behaviour withrelease/win-unpacked/WingMan.exe, not justnpm run dev.
- Paste your Groq API key in the dashboard and click Save key β that is the only key required for the default engine
- Upload your resume (PDF, parsed locally via PyMuPDF) or paste resume text directly
- Add extra context β job description, role expectations, panel details
- Choose transcription provider (
groqrecommended), model (picker is populated live fromPOST /models), language, overlay preset/opacity, and history toggle - Click Start session β WingMan begins listening to system audio
- The floating overlay shows live transcript and streams answers when interview questions are detected. Use Ask for a follow-up answer⦠for manual prompts (
Ctrl+Shift+Spaceto focus)
| Action | Shortcut |
|---|---|
| Toggle overlay visibility | Ctrl+Shift+H or Ctrl+Alt+H |
| Minimize overlay | Ctrl+Shift+M or Ctrl+Alt+M |
| Focus manual input | Ctrl+Shift+Space |
# Clone
git clone https://github.com/sarthakdev143-lite/wingman.git
cd wingman
# Node deps
npm install
# Python env β the app spawns this venv directly
# (.venv/Scripts/python.exe on Windows, .venv/bin/python elsewhere),
# so install the deps into it rather than globally.
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS / Linux
pip install -r python/requirements.txt # root requirements.txt re-exports this
# Dev mode (Vite renderer + main/preload watch + Electron + Python sidecar)
npm run devnpm run dev is orchestrated by scripts/select-dev-port.mjs (picks a free port β .dev-server.json) and scripts/launch-electron.mjs (VITE_DEV_SERVER_URL). Nodemon watches dist/main + dist/preload and restarts Electron after the vite watch build lands.
| Command | What it does |
|---|---|
npm run dev |
predev + concurrently: dev:renderer + dev:main + dev:preload + dev:electron |
npm run typecheck |
tsc --noEmit |
npm run lint |
eslint --ext .ts,.tsx . |
npm test |
vitest run β unit tests for src/validation.ts and src/csp.ts |
npm run test:python |
node scripts/run-python-tests.mjs (runs the suite with the project venv, not whatever python is on PATH) |
npm run verify |
typecheck + lint + test + test:python (full gate) |
npm run build |
Vite builds renderer + main + preload into dist/ |
npm run package |
verify β build β PyInstaller sidecar (scripts/build-python.mjs) β electron-builder β release/ |
Run a single Python test (use the venv interpreter so numpy/deps resolve):
.venv/Scripts/python.exe python/tests/test_vad.py
.venv/Scripts/python.exe python/tests/test_llm.py ResolveModelsTests.test_retired_model_falls_back_and_is_reportedThere is no JS/TS test runner β verify is the gate.
Copy .env.example to .env:
| Variable | Description |
|---|---|
GROQ_API_KEY |
Optional fallback Groq key (can also be set in the UI via SecureStore) |
DEEPGRAM_API_KEY |
Optional, only for deepgram transcription. Also settable in the UI |
WINGMAN_PYTHON_BIN |
Dev only β path to a custom Python interpreter. Packaged builds always use the bundled server unless it is genuinely missing |
Do not rely on
WINGMAN_PYTHON_BINin packaged builds. Older versions took it unconditionally, and becausemain.tsloads.envviadotenv/configrelative to cwd, launching a packaged build from the repo pointed it at the developer virtualenv.
All routes except OPTIONS require X-Wingman-Token or ?token= when WINGMAN_SERVER_TOKEN is set (server.py:require_server_token). Data-plane routes are called directly from the renderer at http://127.0.0.1:<port>.
| Method | Path | Notes |
|---|---|---|
POST |
/session/start |
{ resume_text, extra_context, language, model, api_key, deepgram_api_key, history_enabled, transcription_provider } |
POST |
/session/stop |
stops capture + transcriber, persists history if enabled |
POST |
/resume/upload |
multipart/form-data PDF β { resume_text } |
GET |
/transcript/stream |
SSE TranscriptEventPayload + usage/notice/status |
GET |
/answer/stream |
SSE AnswerEventPayload |
POST |
/answer/manual |
{ prompt } β SSE answer stream (private queue) |
POST |
/models |
{ api_key } β { models, recommended } (live Groq.models.list()) |
GET |
/history |
{ sessions: SessionHistoryRecord[] } |
GET |
/usage |
{ usage: UsageSnapshot } |
GET |
/health |
{ status, port, platform, capture_warning, audio: {ready, message} } |
POST |
/shutdown |
used by PythonServerManager.shutdown() |
Types mirror src/types/contracts.ts (TS camelCase; Python snake_case β conversion in main.ts:startSession, except SSE/history types which stay snake_case).
# PyInstaller deps (hiddenimports in python/wingman-server.spec)
pip install -r python/requirements-pyinstaller.txt
# Verify + build + package
npm run packageArtifacts land in release/ (NSIS installer WingMan-${version}-setup.exe + win-unpacked/). If you add a new runtime-only Python dependency, add it to hiddenimports in python/wingman-server.spec or the packaged exe will fail at import.
npm run typecheck
npm run lint
npm run test:python
# or
npm run verifyPython tests drive SessionManager through its private methods (_publish_transcript, _flush_pending_question_if_ready, _yield_queue, _on_transcript) and a FakeTranscriber mirroring start/stop/feed. Renaming those breaks the suite even when behaviour is unchanged.
wingman/
ββ src/
β ββ main.ts # AppState, PythonServerManager, IPC, shortcuts
β ββ windowManager.ts # dashboard + overlay windows, hardenWindow()
β ββ secureStore.ts # safeStorage-encrypted keys + settings
β ββ pythonServer.ts # sidecar lifecycle, PORT:<n> handshake, token
β ββ preload.ts # WingmanApi bridge
β ββ App.tsx / renderer.tsx # hash-route branch /overlay vs /dashboard
β ββ types/contracts.ts # WingmanApi, AppState, SessionStatus, UsageSnapshot
β ββ hooks/useSession.ts # session draft, canStart, model catalog
β ββ hooks/useStream.ts # SSE EventSource for transcript/answer streams
β ββ components/Overlay.tsx # draggable/resizable overlay + manual input
β ββ lib/backend.ts # uploadResume, loadHistory, getServerBaseUrl
ββ python/
β ββ server.py # Flask app (see table above)
β ββ session_manager.py # AudioCapture β transcriber β question β answer
β ββ transcriber.py # GroqTranscriber (VAD-gated batch) / DeepgramTranscriber
β ββ vad.py # UtteranceSegmenter (energy VAD, voiced-frame minimum)
β ββ llm.py # LLMClient (resolve_models, retries, reasoning_effort)
β ββ audio_capture.py # WASAPI loopback / sounddevice capture + resample
β ββ usage.py # UsageTracker (speech seconds + LLM tokens β USD)
β ββ resume_parser.py # PyMuPDF extraction
β ββ wingman-server.spec # PyInstaller spec
ββ scripts/
β ββ select-dev-port.mjs # picks free port β .dev-server.json
β ββ launch-electron.mjs # launches Electron with VITE_DEV_SERVER_URL
β ββ build-python.mjs # runs PyInstaller
ββ build/ # icon.png / icon.ico
ββ dist/ # vite output (renderer / main / preload)
ββ release/ # electron-builder output
ββ history/ # persisted session JSON (userData/history in prod)
Four edits, in order: WingmanApi in src/types/contracts.ts β bridge method in src/preload.ts β ipcMain.handle in src/main.ts starting with assertTrustedSender(event) β renderer call site. Numeric payloads via requireFiniteNumber(); enum settings via normalizeSettingsUpdates(). Keys never cross to the renderer β anything needing one reads from SecureStore in main (see app:list-models).
- Keys are encrypted with Electron
safeStorage(OS keychain) inuserData/settings.json; renderer only seesapiKeyStoredbooleans - Local Flask server binds
127.0.0.1ephemeral port and requiresWINGMAN_SERVER_TOKENper request - Capture protection:
WindowManager.hardenWindow()+setContentProtection(true)(andWDA_EXCLUDEFROMCAPTURE0x11 on Windows).WindowsGraphicsCaptureis disabled at startup. Verify withGetWindowDisplayAffinity(PowerShellAdd-Typepath) before reintroducing manual affinity logic - Resume PDFs are parsed locally; nothing leaves the machine except Groq/Deepgram API calls
| Symptom | Fix |
|---|---|
Sidecar prints did not report a port in time or exits instantly |
Early exits are tracked locally (child nulls on exit). Check userData/wingman.log and that Defender/antivirus is not quarantining wingman-server.exe. Try release/win-unpacked/WingMan.exe outside the repo dir so .env does not inject WINGMAN_PYTHON_BIN |
WASAPI loopback device is unavailable / No monitor device found |
Windows: run python -m pyaudiowpatch to list devices, set correct default output. macOS: install BlackHole and create a Multi-Output Device. Linux: enable a PulseAudio/PipeWire monitor source |
| Answers/classifier always say "not a question" or return empty | Model likely retired or a reasoning model hit its token budget. Check POST /models / Groq.models.list() for live IDs; reasoning models (gpt-oss, qwen3) need reasoning_effort: low + larger CLASSIFIER_MAX_TOKENS/ANSWER_MAX_TOKENS |
| 429 rate-limit mid-interview | Expected on free tier β _create() retries per Retry-After (capped at 10 s) and falls back to a sibling model. UI shows notice events. Wait a few seconds or type a manual prompt |
WINGMAN_PYTHON_BIN seems to break a packaged build |
Remove it from .env when testing packaged builds; dev override only |
| No interim transcript with Groq | By design β Groq path is batch and only emits finals. Switch to deepgram provider for interim results |
Issues and pull requests are welcome β see CONTRIBUTING.md
for the dev setup, the npm run verify gate every change has to pass, and the
conventions worth knowing before you touch the audio pipeline or IPC layer.
Found a security issue? Please report it privately β see SECURITY.md.
MIT Β© 2026 Sarthak Parulekar.
Bundled fonts (Space Grotesk, IBM Plex Mono) are licensed separately under the
SIL Open Font License 1.1 β see src/assets/fonts/LICENSE.
Built with Electron + React + Flask. Model IDs are resolved at runtime β never assume a Groq model listed in this README still exists; check POST /models first.