Hold a hotkey, talk, let go — the text is typed where your cursor already is. Speech recognition that runs entirely on your own GPU, with a Windows 11 tray app, an optional AI cleanup pass and an OpenAI-compatible HTTP endpoint.
Nothing is uploaded. No account, no API key, no network calls except the ones you ask for — the model is downloaded once and everything after that is local.
- What it does
- System requirements
- Install
- Hotkeys
- Settings
- Delivery and the clipboard
- Dictionary
- AI cleanup
- Voice commands
- HTTP API
- Self-test and health
- Autostart
- Files and folders
- Dependencies
- Does it run on macOS or Linux?
- Privacy
- License
| Dictate anywhere | Hold Ctrl+Win, talk, let go. The text goes into whatever field had focus — a browser, an IDE, a chat box, a terminal. Tap instead of hold to start, tap again to stop. |
| Runs on your GPU | faster-whisper on CTranslate2, large-v3-turbo by default. Several times faster than real time on a modern card. |
| AI cleanup | Ctrl+Shift+Win sends the transcript through a local Ollama model first, which strips filler words and fixes punctuation without changing what you said. |
| Voice commands | Ctrl+Alt+Win matches what you said against commands.json and runs it. Nothing is typed. Only listed actions can run. |
| Types or pastes | Types character by character by default, which works in fields that block paste; switch to clipboard paste when you want speed. |
| Rebindable | Every hotkey is yours to change, from the settings window. Any key, not just modifiers. |
| HTTP API | An OpenAI-compatible /v1/audio/transcriptions endpoint on 127.0.0.1, so other tools can use the same running model. |
| Says what is wrong | A self-test checks the driver, VRAM, CUDA DLLs, microphone and port, and says what to do about each failure instead of crashing later. |
| Required | Notes | |
|---|---|---|
| OS | Windows 10 version 2004 (build 19041) or newer, 64-bit | Windows 11 is what it is developed against; the tray menu and settings window draw the Windows 11 look. |
| GPU | NVIDIA, compute capability 7.0 or newer (RTX 20-series, GTX 16-series, Volta and up) | There is no CPU fallback. LocalSTT refuses to start without a usable CUDA device, and says why. |
| VRAM | 4 GB free for the default model | See the table below. On a tight card, drop the compute type to int8_float16. |
| Driver | NVIDIA 527.41 or newer | What the CUDA 12 wheels need. No CUDA Toolkit installation is required — the wheels carry their own DLLs. |
| Python | 3.10 – 3.13, 64-bit | 3.12 is what it is built and tested against. The installer offers to install it for you if there isn't one, and finds an existing one whether or not it is on PATH. |
| Disk | ~4 GB | ~2.5 GB of Python packages, plus the model (1.6 GB for large-v3-turbo). |
| Microphone | Any input Windows can see | Picked in Settings → Audio, or left on the Windows default. |
| Ollama | Optional | Only for the AI cleanup hotkey. Everything else works without it. |
VRAM the model actually needs, measured with beam_size 5 on a ~30 s window:
| Model | float16 | int8_float16 | int8 | Download |
|---|---|---|---|---|
large-v3-turbo (default) |
2.4 GB | 1.5 GB | 1.2 GB | 1.6 GB |
large-v3 |
3.2 GB | 1.9 GB | 1.6 GB | 3.1 GB |
medium |
1.8 GB | 1.1 GB | 0.9 GB | 1.5 GB |
small |
0.9 GB | 0.6 GB | 0.5 GB | 0.5 GB |
Cleanup runs an Ollama model on the same card, so it has to fit in what is left over.
Settings → AI cleanup works out what fits and offers to download it.
git clone https://github.com/iFrosta/localSTT.git
cd localSTT
.\install.ps1That is the whole thing. The installer stays inside the folder it is run from:
- checks Windows, Python and the GPU — and offers to install Python with
wingetif there is none, - creates
.venvnext to itself, - installs the tested dependency set (
-Latestinstalls the newest instead), - installs the cuBLAS and cuDNN wheels — no CUDA Toolkit needed,
- writes
%APPDATA%\LocalSTT\config.jsonif there isn't one, - runs the self-test.
Useful switches:
.\install.ps1 -Autostart # also start LocalSTT with Windows
.\install.ps1 -Latest # newest packages instead of the pinned set
.\install.ps1 -InstallPython # install Python with winget without asking
.\install.ps1 -SkipSelfTest # skip the check at the endPython does not have to be on PATH. The installer also looks where the python.org
installer puts a per-user install, and at a Python312\ folder next to itself — so a
"put everything in one folder" setup works without touching the system.
If PowerShell refuses to run the script, allow scripts for this session:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy BypassBy default LocalSTT runs from the folder you cloned into — portable, no admin, and
git pull upgrades it. To install it somewhere proper instead, -InstallTo copies the
application there and builds the environment in the copy:
# Per user. No administrator, and this is the recommended one.
.\install.ps1 -InstallTo "$env:LOCALAPPDATA\Programs\LocalSTT"
# All users. Needs an elevated PowerShell.
.\install.ps1 -InstallTo "$env:ProgramFiles\LocalSTT"Program Files works, but per user is the better fit. The dependencies live in a
virtual environment inside the install folder, so every upgrade writes there — under
Program Files that means an elevated console every single time, for a tool only one
person uses. %LOCALAPPDATA%\Programs is where VS Code, Ollama and Python put
themselves for the same reason.
If you do install for all users, everything still works: settings, history and logs
already live in %APPDATA%\LocalSTT, and an edit to commands.json from the settings
window is written to a copy there rather than failing against the read-only original.
One registry key is written, under HKCU\Software\Classes\AppUserModelId: the name
and icon Windows puts on LocalSTT's notifications, which would otherwise be Python's.
The uninstaller removes it. Nothing else goes near the registry, and the folder can be
moved or renamed afterwards — every script finds itself. To undo it all, run the uninstaller from the install
folder:
.\uninstall.ps1 # stops it, removes autostart and the venv
.\uninstall.ps1 -AppData # also delete settings, history and logsReleases carry a LocalSTT-<version>-win64.zip with a Python and every dependency
already inside it. Unzip it anywhere and run start-localstt.vbs — no Python to
install, no pip, no virtual environment. It is around 1.5 GB, because the cuBLAS and
cuDNN runtimes alone are 1.3 GB of that.
Use the archive if you just want the thing to work; clone the repository if you want to
follow changes with git pull.
.\start-localstt.vbs # tray only, no console
.\run-dev.ps1 # in this console, with the log on screenThe tray icon shows the state by colour: green ready, red recording, blue transcribing, purple cleanup, orange command, yellow error.
| Chord | What it does |
|---|---|
Ctrl+Win |
Dictation. Hold and release, or tap to start and tap again to stop. |
Ctrl+Shift+Win |
Dictation with AI cleanup before the text is delivered. |
Ctrl+Alt+Win |
Voice command. Nothing is typed. |
| any of the three | Stops whatever recording is running, whichever mode started it. |
Esc |
Cancels: nothing is transcribed, delivered or run. |
Hold longer than hotkey_tap_seconds (0.45 s) and it behaves as push-to-talk; a shorter
tap toggles instead.
Settings → Hotkeys. Click a field, press the combination, let go — the chord is
recorded by exactly the code that later has to recognise it.
- Any key can take part, not only modifiers.
F9andCtrl+Alt+Dwork as well asCtrl+Win. - The ✕ on the right of a field clears a binding, leaving that mode without a hotkey.
- Two modes cannot share a chord — saving says which two clash instead of applying it.
- A new chord takes effect on save. No restart.
They are stored in config.json as hotkey_dictation, hotkey_cleanup,
hotkey_command and hotkey_cancel, in the form "ctrl+shift+win".
One subtlety: cleanup is dictation plus Shift, so both chords match the same keys.
The recording starts on the first chord and the mode is settled once
hotkey_mode_grace_seconds (0.2 s) has passed — long enough for the rest of a chord
pressed as one gesture. Bindings that do not overlap are unaffected.
Right-click the tray icon → Settings. Every field in config.json is here, grouped,
with Open config.json at the bottom for the ones a UI should not pretend to own.
Changing the model, compute type or API port needs a restart; everything else applies on
save.
| Page | Holds |
|---|---|
| General | Model, compute type, language, beam size, voice activity filter, start with Windows |
| Audio | Microphone, sample rate, input normalisation |
| Hotkeys | The four bindings, the tap threshold and the chord grace period |
| Delivery | Typing vs pasting, the clipboard setting, typing speed |
| Voice commands | Every command with whether it works on this machine, and the timings |
| AI cleanup | Which Ollama model, whether it fits in the VRAM left over, and a download button |
| History | Off by default. On, every transcript is kept with its timestamp |
| Performance | How long the last dictation and cleanup took |
| API | Host and port |
| Health | The full self-test, re-runnable |
large-v3-turbo is multilingual: one model, around a hundred languages, and the default
auto setting works out which one you are speaking on every dictation. Nothing needs
configuring for that — dictate in another language and it follows you.
The dropdown itself offers auto, en and ru, because pinning a language is a little
more accurate on short phrases and most people only ever use one or two. Any other code
works — add it to allowed_languages in config.json and it appears in the list:
"allowed_languages": ["auto", "en", "de", "es"]Turbo is a distilled model, so on some languages it is a little behind large-v3. If
yours is one of them, switch the model in Settings → General; the trade is about a
gigabyte of VRAM and some speed.
Two ways to get the text into the focused field:
- Typewrite (default) — sends the characters one at a time through
SendInput. Works in fields that refuse a paste, and needs no clipboard at all. - Paste — puts the text on the clipboard and sends
Ctrl+V. Instant for long text.
The clipboard is left alone by default. Typing needs no clipboard, so whatever you
had copied is still there after dictating. Turn on Leave the transcript in the clipboard (also in the tray under Delivery) if you want each transcript to end up
there as well. Paste delivery has to borrow the clipboard either way — with the setting
off, it puts the previous contents back afterwards.
The last transcript is always written to %APPDATA%\LocalSTT\last-transcript.txt,
whatever the clipboard setting says.
Whisper mangles technical vocabulary, and it spells product names the way they sound
rather than the way they are written. dictionary.json addresses both:
{
"terms": ["CUDA", "cuDNN", "PostgreSQL"],
"replacements": { "kuda": "CUDA", "postgres sequel": "PostgreSQL" }
}termsare given to the model as context, so it is likelier to spell them correctly. This is conditioning, not learning: Whisper's weights never change, and it remembers nothing between dictations. Whisper reads about 220 tokens of prompt and silently drops the rest, so a term list that keeps growing quietly stops working — the log says so when you cross the line.replacementsare applied to the transcript afterwards, longest phrase first. This is the reliable half. If a word comes out wrong every time, fix it here rather than hoping a longer term list will help.
The file is re-read whenever it changes, so a new word takes effect on the next
dictation — no restart. The shipped one is a starting point; replace it with your own
vocabulary. An edited copy at %APPDATA%\LocalSTT\dictionary.json wins over the one in
the folder, so git pull cannot overwrite it.
Ctrl+Shift+Win transcribes and then rewrites the result with a local Ollama model
before delivering it: filler words out, punctuation in, meaning and language untouched.
Technical terms, paths, commands and code are explicitly left alone.
The model shares the GPU with Whisper, so the right one is whatever fits in the VRAM left over. Let LocalSTT pick and download it:
.\install-qwen-cleanup-model.ps1To see the recommendation without changing anything:
.\.venv\Scripts\python.exe -m localstt.cleanup_modelTo pick one yourself, adding -Pull if it still has to be downloaded:
.\set-ollama-cleanup-model.ps1 -Model qwen3:4b-instruct -TimeoutSeconds 60Settings → AI cleanup shows the same recommendation with a download button. Without
Ollama running, plain dictation is unaffected — only the cleanup hotkey needs it.
The instructions the model is given live in cleanup-prompt.txt, and
Settings → AI cleanup → Edit prompt opens it. Rewrite it and the next dictation uses
the new wording — no restart. Ask for a different tone, translation into another
language, bullet points, a summary; the transcript is the user message and this file is
the system one.
Each dictation is a single stateless request to Ollama's /api/chat: one system message
holding this prompt, one user message holding the transcript. There is no conversation
and nothing is remembered between dictations, so the prompt is the only thing steering
the result.
An edited copy at %APPDATA%\LocalSTT\cleanup-prompt.txt wins over the one in the
folder. Empty the file to fall back to the built-in prompt.
Ctrl+Alt+Win records, transcribes and matches against commands.json. Command mode
keeps listening and stops by itself as soon as something matches, so there is no need to
press the hotkey a second time:
- the running recording is transcribed every
command_poll_seconds(0.9 s), - an exact match runs immediately and ends the session,
- a wildcard match waits for
command_capture_silence_secondsof silence first, so the captured text is the whole phrase and not half of it, - if nothing matches, the session ends after
command_silence_timeout_secondsof silence, orcommand_listen_timeout_secondsin total.
Set command_auto_stop to false (tray: Command auto-stop) for plain start/stop.
A command has to open the phrase, so chatter cannot trigger it. lock the computer
matches "Lock the computer." and "lock the computer please", but not "and then I said
lock the computer".
| Pattern | Matches |
|---|---|
lock the computer |
the phrase at the start of what was said |
add a task * |
the phrase at the start; the rest becomes the capture |
set a timer for * minutes |
a phrase around a capture |
*blackout |
anywhere in the sentence — opt-in, and easier to trigger by accident |
Order matters: the first command whose phrase opens the sentence wins, and
open a terminal also opens open a terminal ubuntu, so list the specific variants
before the general one.
Only what is listed in commands.json can run. Speech never executes an arbitrary shell
command.
process— an existing.exe,.cmd,.bat,.ps1or.vbswith a fixed argument list..ps1runs throughpwsh.exe,.vbsthroughwscript.exe.localstt— acts on the running app:language,delivery(paste/typewrite) orrepeat, which delivers the last dictated text again.microsoft_todo— queued into%APPDATA%\LocalSTT\todo-queue.jsonluntil Microsoft Graph is wired up.app_launch— opens whatever application the capture names, with no entry of its own.
Paths may use %LOCALAPPDATA%-style variables, or a bare executable name resolved
through PATH. A command may also declare what it needs:
requires entry |
Satisfied when |
|---|---|
path:C:\tools\Thing.exe |
that file exists |
exe:wsl.exe |
the name resolves through PATH |
appx:Microsoft.WindowsCalculator_8wekyb3d8bbwe |
that Store package is installed |
wsl:Ubuntu:htop |
htop is on the PATH inside the Ubuntu distro |
Commands whose target is missing are switched off automatically rather than failing when
spoken. Settings → Voice commands lists every command with the reason it is off, and
its toggle writes "enabled": false back into the file.
The shipped set is deliberately plain — lock, sleep, terminals, Notepad, Explorer, VS
Code, the localstt actions, a To Do capture and the catch-all launcher — and its
phrases are English. It is a starting point, not a vocabulary: a command may carry
phrases in as many languages as you like, since Whisper transcribes whichever one you
spoke.
"patterns": ["lock the computer", "ordinateur verrouille", "sperr den rechner"]Add your own, or put an edited copy in %APPDATA%\LocalSTT\commands.json, which wins
over the shipped one — that keeps your set out of git's way, and git pull can never
overwrite it.
app_launch matches against every app the Start menu can open — desktop shortcuts, Store
packages and registered app ids alike, via Get-StartApps. The index is cached in
%APPDATA%\LocalSTT\app-index.json and rebuilt daily, or on demand from
Settings → Voice commands → Rebuild app index.
Spoken names rarely match registered ones, so they are resolved through fuzzy matching,
transliteration (for dictation languages not written in the Latin alphabet) and an alias
file at %APPDATA%\LocalSTT\app-aliases.json (Settings → Voice commands → Edit app names):
{ "chrome": "Google Chrome", "vs code": "Visual Studio Code" }Results are logged to %APPDATA%\LocalSTT\command-history.jsonl (tray: Command history).
The running app serves an OpenAI-compatible endpoint on 127.0.0.1:7777, so other tools
can reuse the model that is already loaded.
curl.exe -X POST http://127.0.0.1:7777/v1/audio/transcriptions `
-F "file=@C:\path\sample.wav" `
-F "model=large-v3-turbo" `
-F "language=ru" `
-F "response_format=json"language=auto detects the language, language=en forces English. Or use the wrapper:
.\transcribe-file.ps1 C:\path\sample.wav/health and /metrics report the loaded model, the device and the timings.
LocalSTT tests itself the first time it starts on a machine it has not seen, and repeats that until the machine passes. It checks the NVIDIA driver version, VRAM and compute capability, the CUDA runtime DLLs in the venv, that CTranslate2 sees a CUDA device, that the model and compute type fit in VRAM, the microphone, the API port, which voice commands this machine can run, and whether the cleanup model fits in what is left.
Only failures that make transcription impossible stop the app — a missing microphone or a
busy port are reported and it starts anyway. Run it any time from Settings → Health, or:
.\.venv\Scripts\python.exe -m localstt.preflight
.\health.ps1 # asks the running app insteadThe last result is kept in %APPDATA%\LocalSTT\preflight.json.
LocalSTT asks the GitHub releases API once a day whether a newer version has been
published, and says so in the tray if there is one. It is one GET; nothing about the
machine goes with it. The switch and a Check now button are on Settings → General,
and the footer of the settings window carries the installed version at all times — it
turns into a link to the new release when there is one.
Updating is manual, and depends on how it was installed: git pull in a clone, or
download the newer archive from
Releases and unzip it over the folder.
Settings, history and the model cache live outside the folder, so neither loses them.
Settings → General → Start with Windows adds or removes a shortcut in the Startup
folder and takes effect immediately. From a console:
.\install-autostart.ps1
.\uninstall-autostart.ps1Everything the app writes lives in %APPDATA%\LocalSTT, never in the install folder:
| Path | What |
|---|---|
config.json |
Every setting |
logs\localstt.log |
The application log |
last-transcript.txt |
The most recent transcript |
history.jsonl |
Dictation history, if you turn it on |
command-history.jsonl |
What voice commands did |
performance.json |
Timings for the last dictation |
preflight.json |
The last self-test result |
app-index.json, app-aliases.json |
The launcher's index and your name overrides |
commands.json, dictionary.json, cleanup-prompt.txt |
Optional. Override the copies in the install folder |
Models are downloaded by huggingface_hub into %USERPROFILE%\.cache\huggingface, which
other tools share — deleting the install folder does not remove them.
Installed by install.ps1; the tested versions are pinned in requirements-lock.txt.
Required (requirements.txt)
| Package | For |
|---|---|
faster-whisper, ctranslate2 |
Speech recognition |
sounddevice, numpy |
Capturing the microphone |
pynput |
Global hotkeys |
pyperclip |
Clipboard delivery |
pystray, Pillow |
The tray icon |
fastapi, uvicorn, python-multipart |
The HTTP endpoint |
requests |
Talking to Ollama |
psutil |
The self-test's process and port checks |
Tkinter draws the settings window and the tray menu; it ships with the official Python installer.
Optional
| What it adds | Without it | |
|---|---|---|
requirements-cuda.txt (nvidia-cublas-cu12, nvidia-cudnn-cu12) |
The CUDA 12 runtime CTranslate2 needs, ~1 GB of wheels | Nothing runs, unless a matching CUDA 12 runtime is already on the search path |
| Ollama and a small instruct model | The Ctrl+Shift+Win cleanup pass |
Plain dictation is unaffected |
Windows Terminal (wt.exe) |
The shipped terminal voice commands | Those commands switch themselves off |
No — it is Windows-only today, and not by accident. The parts that would have to be rewritten are the parts that make it what it is:
| What | Why it is Windows-only | What a port needs |
|---|---|---|
| Typing the text | SendInput through user32.dll, with hand-built INPUT structs (text_input.py) |
macOS: CGEventCreateKeyboardEvent, plus Accessibility permission. Linux: XTEST, or uinput under Wayland |
| Focus handling | GetForegroundWindow / SetForegroundWindow, to put the text back where it came from |
macOS: NSWorkspace and the Accessibility API. Wayland: no equivalent, by design |
| Global hotkeys | pynput works on all three, but there is no Windows key on a Mac and the modifier semantics differ | Rebind to Cmd-based chords — the new binding UI already handles that |
| Tray icon and menus | A hand-drawn Windows 11 Fluent menu, DWM dark title bars, rounded corners (winui.py, tray_menu.py) |
A native menu bar item on macOS; the custom drawing does not transfer |
| Settings window | The same Fluent controls, drawn on Tk canvases | Runs as-is under Tk, but would look foreign |
| Autostart | A .lnk in the Startup folder |
A launchd plist, or a .desktop autostart entry |
| Voice commands | .exe / .ps1 / .vbs paths, Store packages, Get-StartApps |
A different launcher model entirely |
| The engine | Nothing — CTranslate2 and faster-whisper are cross-platform | This part would just work |
On Apple Silicon there is a second problem: there is no CUDA. CTranslate2 has no Metal
backend, so a Mac port would run on the CPU — usable on an M-series chip, but slower — or
be rebuilt on something else: whisper.cpp, which has Metal and Core ML support, or MLX.
That is a different recognition backend, not a port.
Roughly: the recognition core, the config, the dictionary, the cleanup pass and the HTTP
API — about half the code — carry over unchanged. The input layer, the tray UI and the
command runner would each be rewritten per platform. backends.py is already an abstract
interface, and text_input.py, window_focus.py, winui.py, tray_menu.py and
autostart.py are where every Windows call lives.
- Audio never leaves the machine. Recognition is local; cleanup talks to Ollama on
127.0.0.1. - Two things reach the network, and nothing else: the model is downloaded from Hugging
Face the first time, and — once a day — the GitHub releases API is asked whether a
newer version exists. The update check sends nothing about the machine, but GitHub
sees the request and therefore the address it came from. Turn it off in
Settings → General → Check for updates, or setupdate_check_enabledtofalse. Pulling an Ollama model is a third, and only when you ask for it. - Dictation history is off by default. With it on, every transcript is stored in
plain text in
%APPDATA%\LocalSTT\history.jsonl. - The clipboard is left alone by default.
- Voice commands can only run what is listed in
commands.json. Speech never executes an arbitrary shell command.
MIT — see LICENSE.


