I wanted to dictate text into my Opencode and notes on Linux.
So I built a small tool around Groq's Whisper API.
Happy to report this solution has been stable for the past 6 months.
Personally my setup differs very slightly because I provision all this with Ansible, but here's the gist of it.
Press a key, talk, and the text is pasted where your cursor is.
You should probably just let an LLM adapt this system to your workflow.
You need a Whisper model to do the work.
My computer isn't very powerful, so I opted to use a free Whisper model provided by Groq.
Sign up at console.groq.com1 and create an API key. The free tier allows 30 requests per minute and 14,400 per day.
Groq runs OpenAI's Whisper on its own LPU hardware. Same model, faster inference: a 30-second clip transcribes in 2-3 seconds instead of 15-30.
Groq can be geo-restricted. If you get connection errors, try your VPN on and off. I had to turn my VPN off to connect. Test from your location before going further, otherwise you will debug the wrong layer.
The packages:
portaudio19-dev (portaudio-devel on Fedora, portaudio on Arch): the C library for audio capture, required by Python's sounddevicexdotool: simulates keyboard input, X11 onlypython3-venv and python3-pip (python-pip on Arch): to build an isolated environment# Ubuntu/Debian
sudo apt install portaudio19-dev xdotool python3-venv python3-pip
The executable tool we will build should live in ~/tools/dictation.
First, get your Grok API key, then:
mkdir -p ~/.config
printf '%s' 'gsk_YOUR_KEY_HERE' > ~/.config/groq.token
chmod 600 ~/.config/groq.token
mkdir -p ~/tools/dictation && cd ~/tools/dictation
python3 -m venv .venv
source .venv/bin/activate
pip install groq sounddevice soundfile numpy scipy PyQt6 onnxruntime
Save the following as dictate.py. About 250 lines. The full production version I run is closer to 1000 lines, but this covers the essentials.
#!/usr/bin/env python3
"""Voice dictation using Whisper API with toggle mode and multiple backends."""
import os
import sys
import signal
import tempfile
import subprocess
import argparse
from pathlib import Path
from dataclasses import dataclass
from typing import Protocol
import sounddevice as sd
import soundfile as sf
# Constants
SAMPLE_RATE = 16000
PID_FILE = Path("/tmp/dictation.pid")
# Backend protocol
class TranscriptionBackend(Protocol):
def transcribe(self, audio_path: Path) -> str:
...
@dataclass
class GroqBackend:
api_key: str
model: str = "whisper-large-v3-turbo"
def transcribe(self, audio_path: Path) -> str:
from groq import Groq
client = Groq(api_key=self.api_key)
with open(audio_path, "rb") as f:
result = client.audio.transcriptions.create(
model=self.model,
file=f,
response_format="text",
)
return result.text
@dataclass
class OpenAIBackend:
api_key: str
model: str = "whisper-1"
def transcribe(self, audio_path: Path) -> str:
from openai import OpenAI
client = OpenAI(api_key=self.api_key)
with open(audio_path, "rb") as f:
result = client.audio.transcriptions.create(
model=self.model,
file=f,
response_format="text",
)
return result.text
def load_api_key(backend: str) -> str:
"""Load API key from env var or token file."""
if backend == "groq":
key = os.environ.get("GROQ_API_KEY", "")
if not key:
token_path = Path.home() / ".config" / "groq.token"
if token_path.exists():
key = token_path.read_text().strip()
return key
elif backend == "openai":
key = os.environ.get("OPENAI_API_KEY", "")
if not key:
token_path = Path.home() / ".config" / "openai.token"
if token_path.exists():
key = token_path.read_text().strip()
return key
return ""
def create_backend(name: str, api_key: str) -> TranscriptionBackend:
"""Factory for transcription backends."""
if name == "groq":
return GroqBackend(api_key=api_key)
elif name == "openai":
return OpenAIBackend(api_key=api_key)
else:
raise ValueError(f"Unknown backend: {name}")
def to_clipboard(text: str):
"""Copy text to clipboard (Wayland or X11)."""
try:
subprocess.run(["wl-copy"], input=text.encode(), check=True)
except FileNotFoundError:
subprocess.run(
["xclip", "-selection", "clipboard"], input=text.encode(), check=True
)
# Process control
def is_running() -> bool:
"""Check if dictation is already running."""
if not PID_FILE.exists():
return False
try:
pid = int(PID_FILE.read_text().strip())
os.kill(pid, 0) # Check if process exists
return True
except (ProcessLookupError, ValueError):
PID_FILE.unlink(missing_ok=True)
return False
def write_pid():
"""Write current PID to file."""
PID_FILE.write_text(str(os.getpid()))
def remove_pid():
"""Remove PID file."""
PID_FILE.unlink(missing_ok=True)
def stop() -> bool:
"""Stop running dictation."""
if not PID_FILE.exists():
print("No dictation running")
return False
try:
pid = int(PID_FILE.read_text().strip())
os.kill(pid, signal.SIGTERM)
print("Stopped dictation")
return True
except (ProcessLookupError, ValueError):
PID_FILE.unlink(missing_ok=True)
print("No dictation running")
return False
# Recording
def record_until_signal() -> Path:
"""Record audio until SIGTERM is received."""
print("Recording... (press shortcut again to stop)")
audio_chunks = []
should_stop = [False]
def callback(indata, frames, time, status):
if status:
print(f"Audio error: {status}")
audio_chunks.append(indata.copy())
if should_stop[0]:
raise sd.CallbackStop()
def signal_handler(signum, frame):
should_stop[0] = True
# Set up signal handler
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# Record until signal
try:
with sd.InputStream(
samplerate=SAMPLE_RATE, channels=1, dtype="int16", callback=callback
):
while not should_stop[0]:
sd.sleep(100)
except KeyboardInterrupt:
pass
# Concatenate chunks
if not audio_chunks:
raise RuntimeError("No audio captured")
import numpy as np
audio = np.concatenate(audio_chunks)
# Save to temp file
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
sf.write(tmp.name, audio, SAMPLE_RATE)
return Path(tmp.name)
def record_fixed(duration: float) -> Path:
"""Record audio for fixed duration."""
print(f"Recording for {duration}s...")
audio = sd.rec(
int(duration * SAMPLE_RATE), samplerate=SAMPLE_RATE, channels=1, dtype="int16"
)
sd.wait()
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
sf.write(tmp.name, audio, SAMPLE_RATE)
return Path(tmp.name)
# Main pipeline
def run_session(backend: TranscriptionBackend, audio_path: Path):
"""Transcribe and output text."""
try:
print("Transcribing...")
text = backend.transcribe(audio_path)
if text.strip():
to_clipboard(text)
print(f"Transcribed: {text[:80]}...")
else:
print("No speech detected")
finally:
audio_path.unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser(description="Voice dictation")
parser.add_argument(
"command", nargs="?", default="toggle", choices=["toggle", "stop"]
)
parser.add_argument("--backend", default="groq", choices=["groq", "openai"])
parser.add_argument(
"--duration", type=float, default=30.0, help="Duration for fixed mode"
)
parser.add_argument(
"--mode", default="toggle", choices=["toggle", "fixed"]
)
args = parser.parse_args()
# Handle stop command
if args.command == "stop":
stop()
return
# Check if already running
if is_running():
print("Dictation already running. Use 'dictation stop' to stop.")
sys.exit(1)
# Write PID
write_pid()
try:
# Load API key and create backend
api_key = load_api_key(args.backend)
if not api_key:
print(f"Error: No API key found for {args.backend}")
print(f"Set {args.backend.upper()}_API_KEY or create ~/.config/{args.backend}.token")
sys.exit(1)
backend = create_backend(args.backend, api_key)
# Record based on mode
if args.mode == "toggle":
audio_path = record_until_signal()
else: # fixed
audio_path = record_fixed(args.duration)
# Transcribe and output
run_session(backend, audio_path)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
finally:
remove_pid()
if __name__ == "__main__":
main()
Keyboard shortcuts run with a minimal environment: no shell init, no PATH, no env vars. A wrapper fixes that. It sets XDG_RUNTIME_DIR, loads the key from the token file when the variable is unset, and execs the venv Python.
Save this as ~/tools/dictation/dictate:
#!/bin/bash
# Wrapper for dictation - handles environment for keyboard shortcuts
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
# Load API key
if [ -z "$GROQ_API_KEY" ] && [ -f "$HOME/.config/groq.token" ]; then
export GROQ_API_KEY=$(cat "$HOME/.config/groq.token")
fi
if [ -z "$OPENAI_API_KEY" ] && [ -f "$HOME/.config/openai.token" ]; then
export OPENAI_API_KEY=$(cat "$HOME/.config/openai.token")
fi
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
exec "$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/dictate.py" "$@"
Then make it executable and symlink it into your PATH:
chmod +x ~/tools/dictation/dictate
ln -s ~/tools/dictation/dictate ~/.local/bin/dictation
Bind dictation to a key on your setup:
dictationdictationbindsym $mod+Shift+d exec dictationsuper + shift + d
dictation
One caveat: sway is Wayland. Typing needs ydotool; the clipboard still works.
wl-copy for the clipboard or ydotool for typing.audio group.You now have press-a-key dictation with toggle mode, process control, signal handling, and multiple backends. Ten minutes, one free API key, no desktop-specific tooling.
The full version I run grew into more: streaming mode (words appear as you speak), VAD silence trimming to prevent hallucinations, a system tray indicator, and an Ansible role so I can provision it across machines. That is closer to 1000 lines. But the 250-line script above is the daily driver.
Groq console, where you sign up and create an API key.
↩