Kevin Cojean

Enabling speech-to-text in Opencode (or anything else!)

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.

What you need

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 sounddevice
  • xdotool: simulates keyboard input, X11 only
  • python3-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

Project setup

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

The core script

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 on any desktop

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:

  • KDE Plasma: System Settings -> Shortcuts -> Custom Shortcuts -> New -> Command/URL, command dictation
  • GNOME: Settings -> Keyboard -> Custom Shortcuts, command dictation
  • i3/sway: bindsym $mod+Shift+d exec dictation
  • Any WM with sxhkd:
super + shift + d
    dictation

One caveat: sway is Wayland. Typing needs ydotool; the clipboard still works.

Gotchas

  • X11 vs Wayland: xdotool only works on X11. On Wayland, use wl-copy for the clipboard or ydotool for typing.
  • VPN: Groq may block your region. Test early; your VPN may need to be on or off.
  • Microphone permissions: some distros need your user in the audio group.
  • PipeWire vs PulseAudio: both work with PortAudio. What matters is that the audio server is running.
  • Cost and latency: Groq's free tier is generous. OpenAI's Whisper endpoint costs about $0.006/minute if you prefer it, but adds 10-30 seconds of latency against Groq's 1-3.

Conclusion

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.


  1. Groq console, where you sign up and create an API key.


Published on 2026-08-10
Last updated on 2026-08-10