Capture audio from any application (Microsoft Teams, Zoom, Google Meet, Discord, browser playback, …) on Windows using WASAPI loopback, optionally mix in your microphone, and transcribe locally with faster-whisper — with optional speaker diarization.
Teams / Zoom / browser audio ─┐
├──► mixed WAV ──► faster-whisper ──► .txt / .srt / .json
microphone ─────┘ │
└──► pyannote diarization ──► speaker-labeled transcript
Everything runs locally. No cloud calls, no virtual audio drivers, no admin rights required.
- Loopback recording of any app's audio via WASAPI (
pyaudiowpatch) - Optional mic mixing — record system audio + your voice into a single file
- Local transcription with faster-whisper (CTranslate2, ~4× faster than
openai-whisperon CPU) - Speaker diarization with pyannote.audio (optional)
- Speaker enrollment — name your colleagues once, get labeled transcripts forever
- Timestamped references - add links or capture desktop screenshots while recording, then jump back to that moment during playback
- Two interfaces: terminal CLI (
capture.py) and web UI (app.py) - Batch processor with watch mode (
batch_transcribe.py) - Outputs
.wav,.txt,.srt,.jsonper recording, organized into per-recording subfolders
pip install -r requirements.txtCore requirements: PyAudioWPATCH, numpy, faster-whisper, flask. Diarization adds pyannote.audio.
Diarization (optional) requires a free HuggingFace token and accepting the model terms:
- Create a token at https://huggingface.co/settings/tokens
- Accept the terms at https://huggingface.co/pyannote/speaker-diarization-3.1
- Set
HF_TOKEN=<your_token>in your environment (or a.envfile)
ffmpeg must be on your PATH for partial-segment transcription.
python capture.pyLaunches an interactive menu:
| Key | Action |
|---|---|
r |
Start recording |
t |
Transcribe an existing recording |
n |
Rename a recording (renames .wav / .txt / .srt / .json together) |
l |
Listen to a transcription |
d |
List audio devices |
c |
Configure settings (model, output dir, mic, auto-transcribe) |
o |
Open recordings folder |
q |
Quit |
While recording, press l to add a link, p to capture a full-desktop screenshot, s to stop, or q to abort.
python app.py # http://127.0.0.1:5000
python app.py --port 8080 # custom port
python app.py --host 0.0.0.0 # expose on LAN (use with care)The web UI mirrors the CLI: record, add timestamped links/screenshots, browse recordings (with subfolders), jump from annotations to audio timestamps, transcribe (full or segment), diarize, enroll/identify speakers, rename, delete, download, and preview live device levels.
# Record for 60 minutes then auto-stop
python capture.py --record 60
# Record + mix microphone in (with optional volume 0.0–1.0)
python capture.py --record 60 --mic --mic-volume 0.7
# Choose specific devices
python capture.py --device 12 --mic-device 5 --record 30
# Transcribe an existing file
python capture.py --transcribe path/to/recording.wav --model medium
# Transcribe only a segment (seconds, or MM:SS via the interactive menu)
python capture.py --transcribe recording.wav --start-time 60 --end-time 300
# List available audio devices (loopback + input)
python capture.py --list-devices
# Show detected compute hardware (CUDA / CPU compute types)
python capture.py --gpu-info
# Custom name / output directory
python capture.py --record 30 --name "Sprint planning" --output-dir D:\MeetingsUseful flags:
| Flag | Description |
|---|---|
--record MIN |
Record for N minutes then auto-stop (omit for indefinite) |
--transcribe PATH |
Transcribe an existing .wav and exit |
--remote-transcribe PATH |
Send a recording to the host, watch its progress, download the transcript |
--remote |
With --record: transcribe on the host instead of locally |
--upload-url URL |
Host endpoint, e.g. https://exposer.<account>.workers.dev |
--upload-user / --upload-password |
Credentials for the host's proxy (default user exposer) |
--model NAME |
Whisper model (tiny / base / small / medium / large-v3) |
--mic |
Mix the microphone into the recording |
--mic-device N |
Specific mic device index |
--mic-volume F |
Mic gain, 0.0–1.0 (default 1.0) |
--allow-mic-conflict |
Don't bail when the mic might already be in use by Teams/Zoom |
--device N |
Specific loopback device index |
--name NAME |
Custom recording filename |
--output-dir DIR |
Override output directory |
--start-time / --end-time |
Trim a segment for partial transcription (seconds) |
--list-devices |
Print loopback + input devices |
--gpu-info |
Print detected compute hardware and current device/compute-type settings |
Process all .wav files in the recordings folder that don't yet have a .txt:
# One-shot: transcribe everything pending
python batch_transcribe.py
# Watch mode: auto-transcribe new recordings as they appear
python batch_transcribe.py --watch
# Pick a specific model
python batch_transcribe.py --model mediumRecord on a machine that has no models — a work laptop, say — and let the beelink do the transcription and diarization. One action sends the recording, shows the host's progress live, and drops the transcript into the recording's own folder. From the client's point of view it looks like a local transcription that happens to be fast.
.\setup-remote-host.ps1 # sets SHARE_ROOT + UPLOAD_HOOK, checks the stack
.\start-exposer.cmd # restart exposer so it picks them upThe hook is remote_job.py. Exposer runs it for every uploaded file; it
transcribes, diarizes, and writes a status document the client polls.
# Send an existing recording and wait for the transcript
python capture.py --remote-transcribe recording.wav `
--upload-url https://exposer.<account>.workers.dev `
--upload-password <proxy password>
# Record for 60 minutes, then transcribe on the host instead of locally
python capture.py --record 60 --remote --upload-url https://exposer.<account>.workers.devInteractive mode has this as [h]; the web UI has a Transcribe Remote
button on every recording, plus Settings -> Remote Processing to store the
URL, credentials, and an optional "transcribe on the host after each
recording" toggle. The password can also come from $REMOTE_UPLOAD_PASSWORD
instead of being saved to the config file.
For one-click use on each client:
- Run
python app.pyon the computer that records the meeting. - In Settings -> Remote Processing, enter Beelink's URL and any required
credentials, then save. On the current LAN, Beelink is at
http://192.168.1.19:8080; through Tailscale, usehttp://100.92.66.97:8080. The LAN address can change. Clients outside those networks need the Exposer tunnel URL. - Record and stop, then click Transcribe Remote beside the recording. Progress appears in the client UI and the returned transcript is saved beside the audio, ready to view or download.
The button uses the saved credentials (or the client's environment) without asking again. Enable Transcribe on the host after each recording if you want stopping the recording to start this round trip automatically.
Keep both host and clients updated: status messages identify the submission so retrying a recording cannot accidentally return its previous transcript.
If the client timed out or was closed, click Resume Remote on the same
recording. This reconnects to the existing job and downloads its results without
uploading or transcribing again. CLI equivalent:
python capture.py --remote-transcribe recording.wav --resume-remote.
After pulling an update, run powershell -ExecutionPolicy Bypass -File .\restart_audio_ui.ps1
and refresh the browser. The restart script refuses to interrupt an active
recording or transcription.
The host publishes a heartbeat every ten seconds even during long model stages. Exposer must serve status JSON from a fully read, closed file, with caching disabled, and close download streams on disconnect. Otherwise Windows can keep the status file locked and prevent progress updates.
The client only ever talks to https://exposer.<account>.workers.dev, the
Cloudflare Worker in front of exposer's tunnel — no LAN access, no direct
route to the beelink. Every request carries HTTP Basic auth for that Worker.
Audio is re-encoded to 16 kHz mono FLAC before it is sent: Whisper and pyannote both resample to that internally, so nothing they use is lost, and an hour of 48 kHz stereo drops from roughly 660 MB to 55 MB. That keeps uploads tolerable on a VPN and well inside the Worker's request-body limit.
Cloudflare caps that body at 100 MB on Free and Pro plans, which works out to
roughly 1.8 hours of audio per job. Longer recordings are rejected before
the upload starts, with a message saying so — split them, or send them over a
direct http:// LAN/Tailscale URL, which has no such cap.
On the host, under <SHARE_ROOT>:
audio-inbox/<client-host>/<recording>/
<recording>.flac # uploaded audio
<recording>.request.json # what the client asked for (model, language, diarization)
<recording>.status.json # live progress — this is what the client polls
<recording>.joblog.jsonl # structured per-job log
<recording>.txt/.srt/.json # results
On the client, .txt, .srt and .json are written next to the original
.wav, exactly as a local transcription would.
Every stage is reported, so failures surface on the client rather than hanging. If a job dies, read the host's log for that recording:
# from the client, over the same endpoint
curl -u exposer:<password> "https://exposer.<account>.workers.dev/api/download?path=audio-inbox/<client-host>/<rec>/<rec>.joblog.jsonl"Each line is a JSON event (job_received, job_stage, job_done,
job_failed) tagged with the same job_id. Exposer discards the hook's
stdout, which is why the log is written to the share instead.
Jobs are serialized on the host — a second upload arriving mid-job reports
queued to its client until the first finishes.
The older behaviour is still available when you only want the file moved:
python capture.py --record 60 --upload-url http://beelink:8080 --upload-path audio-inboxEach recording lives in its own subfolder for tidiness:
~/MeetingRecordings/
recording_2026-05-02_10-15-00/
recording_2026-05-02_10-15-00.wav # raw audio
recording_2026-05-02_10-15-00.txt # plain text (with timestamps if diarized)
recording_2026-05-02_10-15-00.srt # subtitles
recording_2026-05-02_10-15-00.json # structured segments + language (batch only)
recording_annotations.json # timestamped link/screenshot metadata
screenshots/
screenshot_00-12-34_ab12cd34.png # full desktop captured at 00:12:34
| Model | RAM/VRAM | Speed | Accuracy | Best for |
|---|---|---|---|---|
tiny |
~1 GB | Fastest | Basic | Quick drafts |
base |
~1 GB | Fast | Good | Default — daily meetings |
small |
~2 GB | Medium | Better | Most meetings |
medium |
~5 GB | Slow | Great | Important meetings |
large-v3 |
~10 GB | Slowest | Best | Critical / multilingual |
CPU users get a quality boost from int16 compute type (already enabled). For GPU, install CUDA-enabled ctranslate2 and the model loader will pick it up automatically.
When diarization is enabled, transcription and diarization run in parallel and the segments are merged into a speaker-labeled transcript with timestamps:
[00:00:03] SPEAKER_00: Morning everyone, let's get started.
[00:00:06] SPEAKER_01: Quick update from my side — …
Use the web UI to enroll a speaker by selecting a clip where only that person talks; future recordings will replace SPEAKER_xx with their name.
Settings are persisted to ~/.audio_capture_config.json. Edit via the CLI c menu, the web UI Settings panel, or directly:
{
"output_dir": "C:\\Users\\you\\MeetingRecordings",
"auto_transcribe": true,
"whisper_model": "base",
"device_index": null,
"mic_enabled": false,
"mic_device_index": null,
"mic_volume": 1.0
}- Start recording before the meeting starts — you won't miss intros.
baseis the sweet spot on CPU. Step up tosmall/mediumonly when accuracy matters.- Mute notifications and music — loopback captures everything the speakers play.
- Mic mixing: the mic is opened before the loopback stream for a reason — opening loopback first silently kills mic callbacks under WASAPI.
- The recordings folder uses per-recording subfolders so renames keep all artifacts together.
"No loopback device found"
→ python capture.py --list-devices and look for entries marked LOOPBACK. Update audio drivers if none appear.
Recording is silent → Confirm audio is actually playing through the device you captured. Some headsets expose multiple endpoints — pick the active one.
Mic stream fails to open
→ The device's advertised channel count may not match WASAPI shared mode. The recorder retries at the advertised count, then stereo, then mono. If Teams/Zoom is already holding the mic exclusively, pass --allow-mic-conflict to bypass the safety check.
Diarization says "model not authorized"
→ Accept the terms on the HuggingFace pyannote page and set HF_TOKEN.
Transcription is slow
→ Use a smaller model, or move to a GPU build of ctranslate2.
Built for Windows (WASAPI loopback). On other platforms you'll need to swap the capture backend:
- Linux: PulseAudio monitor sources (
alsa_output.*.monitor) via plainpyaudio - macOS: a virtual audio device such as BlackHole
Transcription, diarization, batch processing, and the web UI are platform-agnostic.