Voice-driven dictation with a reviewable draft, and live two-track meeting transcription, on Azure AI Foundry's MAI-Transcribe-2.
pks exec heypoul [meeting] [flags]$ pks exec heypoulPush-to-talk dictation with Foundry access wired in by pks
$ pks exec heypoul -- modelsThe chat models pks' catalog offers across its registered providers, with pks' default and the decision model marked
$ pks exec heypoul -- decide --option gpt-5.2 --option gpt-6-astra --option gpt-5.6-luna "den nyeste"What the grammar and the decision model make of a spoken answer
$ pks exec heypoul -- clean --model gpt-5.5 "øhm vi sender det mandag, nej tirsdag"See what a cleanup deployment does to dictated text
$ pks exec heypoul meetingLive transcript of a Teams call: the other side via loopback, you via the microphone
$ heypoul --list-outputsList output devices that meeting mode can capture
$ heypoul --aec-probe 10Diagnose the Windows echo canceller the meeting's Dig track uses (endpoints, samples, HRESULTs, a WAV)
heypoul turns speech into text with Microsoft's MAI-Transcribe-2 on Azure AI
Foundry. It does two things:
--key (a virtual-key
code, e.g. 0x78 for F9) or set HEYPOUL_KEY.heypoul meeting transcribes a call live on two tracks,
labelled Dem (what your speakers or headset play, captured by WASAPI
loopback) and Dig (your microphone). You keep hearing the call as normal; there is no
virtual audio device to install.While it runs, a small pill sits at the bottom of the screen (drag it anywhere): a state orb, the live waveform, and the words as they arrive. Idle it shows which key to hold; after a release it shows what was typed.
Windows (PowerShell):
irm https://agentics.dk/install/heypoul.ps1 | iex
Linux:
curl -fsSL https://agentics.dk/install/heypoul.sh | bash
Released builds are amd64 and cloud-only.
The recommended way to give heypoul access to Foundry is pks exec. Sign in
once with pks foundry init, then:
pks exec heypoul
pks exec heypoul meeting
heypoul never receives an Azure token. pks starts a credential proxy on
127.0.0.1 in front of your Foundry resource and gives heypoul its address
and a throwaway token that only opens that proxy. The proxy attaches a fresh
Entra token to every request and every WebSocket, so a meeting can run longer
than the token's one-hour lifetime.
Without pks, set HEYPOUL_ENDPOINT and either HEYPOUL_API_KEY or
HEYPOUL_TOKEN yourself.
Since 0.3.0 dictation is staged: nothing is typed when you release the key. The text goes into a draft, the end of it shows in the pill with a ▴, and you drive it by voice. Say the wake word "hey poul" (also heard as "hej poul", "hey Paul") followed by a command, alone or at the end of a sentence:
| Say | Does |
|---|---|
hey poul send | Types the draft into the focused window |
hey poul enter | Types the draft and presses Enter; with an empty draft just presses Enter (confirms a prompt in Claude or a terminal) |
hey poul vis tekst / show | Opens the panel: the whole draft, wrapped, with the live words in orange while you hold the key |
hey poul skjul / hide | Back to the pill |
hey poul fortryd / undo | Drops the last hold |
hey poul ryd / start forfra / clear | Drops the whole draft |
hey poul skift model / vælg model | Asks which model with a four-option choice: three models and Flere… for the next three. Answer without the wake word (see Choices) |
hey poul modeller / models | Shows the whole list: every chat deployment on your resource, numbered, the one in use in orange |
hey poul model 2 / brug luna / skift til gpt 5.5 | Switches the cleanup model, by number in the full list or by name; saved to config.json |
hey poul rens / ryd op / clean | Runs the cleanup model now |
hey poul hjælp / kommandoer / vis kommandoer / help | Shows this list in the panel; hey poul skjul closes it |
hey poul lyt med / start mødet / listen | Starts the meeting inside the daemon and opens the transcript view (see Meeting mode) |
hey poul stop mødet / slut mødet / stop listening | Stops the meeting |
hey poul indstillinger / settings | Opens the settings view: language, models, key, word list, devices |
hey poul tilføj ordet Gjellervand / add the word … | Adds a word to the word list, spelled as you said it |
hey poul brug kort 2 / fjern kort 2 / use card 2 | Puts an insight card's text into the draft, or drops the card. Cards come from the meeting transcript (every 20 s, at most four) and show in a running pks-agent-notch when there is one, else in the meeting view |
"Hey poul" inside a sentence stays text when no command follows it. A command at the end of a hold is split off: "Vi tager det i morgen, hey poul send" adds the sentence and sends.
Without the wake word. A hold that is nothing but a command runs it: "Send.", "vis teksten", "fortryd", "skift model" — up to three words, so "send mig lige en mail" is dictation. Inside a longer hold the same bare words run only when they are reversible (show, hide, undo, clean, the model commands); send, enter and ryd in the middle of speech still need hey poul in front, or they are text.
Read at every pause. The stream completes a run of speech at each pause (about half a second of silence), and heypoul reads each run on its own as it completes, before the key is released: a command in the middle of a hold first lands what was said before it, then runs, so "… [pause] hey poul vis tekst [pause] og så videre" opens the panel between the two pieces of text. A wake word followed by words the grammar does not know ("Hey Paul, vi teksten" is how vis teksten is sometimes heard) is sent to the decision model right away, with the command list as the options and none among them; a confident command runs when the hold lands, anything else stays text, wake word and all. Corrections to the content ("nej, ret det til tre") need no command at all: the cleanup model applies them.
Cleanup. The draft is rewritten by a chat deployment after every hold:
fillers and false starts go, and self-corrections are applied, so "to, nej
faktisk tre tusind" becomes "tre tusind" and "ret kunden til Andel"
replaces the name. Each cleanup reads the previous cleaned text followed by
the new hold, not the raw words from the start, so a correction applied once
stays applied and the panel never flashes the old words while the model
works; the header says renser meanwhile. The panel always shows exactly
what send will type. A name the model picker cannot match ("skift mod
den") opens the four-option choice instead of failing.
Choices. Whenever heypoul needs a decision from you it asks the same way: a title and at most four numbered options fill the panel, and the next hold answers it, no wake word needed. Say the number ("to", "nummer tre", "den anden"), the name or enough of it ("luna", "gpt fem to"), or "flere" for the next page. Those the grammar reads on its own, and it reads them from the live words, so a "tre" is taken while the key is still down. Anything else ("den nyeste", "nej ikke luna, den anden") goes to the decision model, which answers in a quarter second with a confidence; below 0.85 heypoul says ikke forstået · sig et tal and keeps asking. A confident none (you went on dictating over the question) closes it and keeps your words. "annuller", "luk" or "hey poul skjul" close it. Four options, numbered, is a habit that carries to a car or a phone unchanged.
Which model. Nothing needs configuring: at start heypoul reads the model
catalog pks serves at /pks/v1/models (every deployment on your resource,
with the default and the enabled set from pks foundry select) and takes
pks' default when it can chat, else the newest plain GPT deployment. The
panel header names it. hey poul skift model asks with a choice; hey poul brug luna switches by name; either way one probe request first proves the
deployment answers (the Claude deployments on a Foundry resource do not on
this route; the HUD says svarer ikke and keeps the old model). A spoken
switch is saved to config.json; HEYPOUL_TEXT_MODEL overrides it.
The decision model is the deployment pks foundry select names as Voice
classifier model when that is a Decision model (Decision-1), else any
Decision deployment on the resource, else none, in which case answers must
be a number or a name. HEYPOUL_DECISION_MODEL overrides. From the shell:
pks exec heypoul -- models # numbered, * = in use, [pks default], [judges answers]
pks exec heypoul -- models --json # OpenAI list shape with enabled, roles, output
pks exec heypoul -- decide --option gpt-5.2 --option gpt-6-astra --option gpt-5.6-luna --option Flere "den nyeste"
With pks ≥ 7.10.0 the list is pks' catalog across every registered provider
(Foundry, Gemini, Anthropic, OpenAI, Moonshot), each row saying who serves
it, and cleanup goes through pks' chat route so heypoul never holds a key.
The switch flow in pictures, the voice answers and the fallback for older
pks are in the tutorial Skift model med stemmen. The
catalog's shape, where it comes from and the measured Decision-1 verdicts
are in docs/model-catalog.md.
Try a deployment first:
pks exec heypoul -- clean --model gpt-5.5 "øhm vi sender det mandag, nej tirsdag"
gpt-5.5 and gpt-5.6-luna both answer in 1.5–2 s on a 40-word draft.
Direct mode. HEYPOUL_MODE=direct (or "mode": "direct" in config.json)
types the text on release as before 0.3.0; voice commands are off there.
The panel is clickable since 0.9.0: ▴ opens it, the button at the header's
right closes it, a row of a choice or of the model list picks it, the
settings rows open their choices, × on a word-list chip removes the word,
stop in the meeting view stops the meeting. A click does exactly what the
spoken command does. The overlay should not take the focus from the window
you dictate into (WS_EX_NOACTIVATE); if it does on your desktop, start
with HEYPOUL_RESTORE_FOCUS=1 and the focus is put back before each
injection, and say so.
hey poul indstillinger, or Settings… in the tray, opens the settings
view in the panel. Each row is a four-option choice, so it is answered by
voice or by click; tryk ny tast waits for the next key you press. Changes
land in config.json at once and apply without a restart.
Panel height and idle. Drag the panel's top edge (the small bar) to make
it taller, up to the top of the screen, or shorter, down to 240 dp; the
height is saved as panelH in config.json (0 = the built-in 420). After
eight seconds with nothing happening and the pointer elsewhere the panel
dims to about half; any change, a spoken word or a hover brings it back.
The pill never dims.
In the daemon: hey poul lyt med, or Lyt med in the tray, or a click on
stop / the green dot to end it. The meeting view shows the newest sixteen
lines of both tracks, the unfinished sentence of each in orange, and the
header carries a green lytter dot in every view. Push-to-talk keeps
working; what you dictate also lands in the Dig track. The same engine
runs standalone for scripts:
pks exec heypoul meeting
Each meeting gets a folder (Documents\heypoul\meetings\<date_time> on
Windows) with:
| File | Content |
|---|---|
them.wav, me.wav | 16 kHz mono recordings of each track |
live.md | Readable live transcript, [hh:mm:ss] Dem/Dig: … |
live.jsonl | One JSON line per final segment |
The live text is fast but has no phrase list. For names and domain terms, transcribe the WAV files again afterwards in batch mode.
| Flag | Default | Meaning |
|---|---|---|
--them-device | default output | Output device to capture (substring of its name; see --list-outputs) |
--me-device | configured mic | Microphone (substring of its name) |
--no-me | off | Only transcribe the other side |
--out-dir | see above | Where the meeting folder goes |
--language | da-DK | Language of the call |
--rotate | 50m | Open a fresh streaming session this often (the service closes them after an hour) |
On loudspeakers the microphone also picks up the other side, so on Windows
the "Dig" track is opened through the operating system's own echo canceller
(the Voice Capture DSP, in Windows since Vista); it subtracts what the output
device plays and keeps the two device clocks in step itself. If it cannot be
opened the header says why and the raw microphone is used; HEYPOUL_AEC=0
skips it, and heypoul --aec-probe 10 prints a JSON diagnosis with the
endpoints, the samples that came out and a WAV to listen to. Loopback
captures everything the output device plays, including notification sounds
and music.
With engine=stream the words should show in the pill while the key is held.
If they only arrive after release, the stream failed and heypoul fell back to
batch transcription. Since 0.2.1 the console (and %TEMP%\heypoul.log on
Windows, /tmp/heypoul.log elsewhere) says why, one line per attempt:
stream connected in 412ms (ws://127.0.0.1:53342/mai/v1/realtime?intent=transcription)
text from stream (23 chars, final after 180ms)
or
stream dial failed after 10s: realtime dial ws://…: context deadline exceeded
stream gave no result after 5s (…) — batch transcription instead
A stream rejected the session line means the service refused the
session.update; heypoul then falls back to batch at once. The common case
is misleading on the wire:
stream rejected the session: invalid_value: PCM input rate must be 24000, or 16000 for MAI transcription. — the resource behind the endpoint does not know MAI-Transcribe-2-Streaming; …
The rate is not the problem. The service answers this when the model name
does not resolve on the Foundry resource, which happens when
MAI-Transcribe-2-Streaming is not deployed there or the resource is in a
region that does not serve it (served from swedencentral, centralus,
eastus2 and southeastasia). Batch MAI-Transcribe-2 is available more
widely, which is why push-to-talk still works. Fix it on the resource: deploy
the model, or run pks foundry init and pick a resource in one of those
regions. heypoul meeting --replay-them <wav> exercises the same stream
without a microphone and prints the same lines.
| Variable | Meaning |
|---|---|
HEYPOUL_ENDPOINT | Foundry resource endpoint (set by pks exec) |
HEYPOUL_TOKEN / HEYPOUL_API_KEY | Bearer token or resource key (set by pks exec) |
HEYPOUL_KEY | Push-to-talk key; Windows virtual-key code, default 0xA5 (Right Alt) |
HEYPOUL_ENGINE | stream (default, live words during push-to-talk) or cloud (batch only) |
HEYPOUL_MODE | draft (default: review and send by voice) or direct (type on release) |
HEYPOUL_TEXT_MODEL | Chat deployment that cleans the draft; unset picks the catalog's default at start (see Which model) |
HEYPOUL_DECISION_MODEL | Decision deployment that judges spoken answers to a choice; unset takes the catalog's (see Which model) |
HEYPOUL_REALTIME_RATE | PCM rate sent to the streaming service, 16000 (default) or 24000; audio is resampled from the internal 16 kHz |
HEYPOUL_LANGUAGE | BCP-47 language, default da-DK |
HEYPOUL_PHRASES | Word list, comma-separated: names, products and jargon the recognizer would spell by ear (at most 100). The batch route and the cleanup model take it; the streaming model does not, so on release the batch route decides the final text while the stream has shown the words |
HEYPOUL_TOKEN_CMD | Command that prints a fresh token; meeting mode runs it per session |
HEYPOUL_THEM_DEVICE | Output device the meeting's Dem track captures (substring of its name); the settings view sets it too |
HEYPOUL_RESTORE_FOCUS | 1 puts the focus back on the window you dictated from before each injection |