Skip to content

About

Murmur and it types. Voice-to-text & instant translation for Linux/Wayland. Whisper-powered, zero dependencies, single Python file.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

murmur-type

Murmur and it types.

Voice-to-text and instant voice translation for Linux/Wayland. Press a hotkey, speak, press again — your words appear in the focused window. Powered by Whisper via Groq (free, blazing fast). Single Python file, zero dependencies.

Works with niri, sway, Hyprland, and any Wayland compositor.

License: MIT Python 3.10+ Wayland Zero Dependencies

Why murmur-type?

Most Linux voice-to-text tools require heavy setups (local models, Python packages, system services). murmur-type is a single file with no dependencies — just Python stdlib, a Groq API key, and your Wayland compositor. It takes 30 seconds to set up.

Features

  • Voice → Type — speak in any language, text appears in the focused window (VSCode, terminal, browser, anywhere)
  • Voice Translate — say a word in one language, see the translation + 3 context examples in a rofi popup with the word underlined in each sentence
  • Ask (chat app) — speak a question, get an answer in a local chat window with a history sidebar (open any past dialog) and typed follow-ups for multi-turn conversations; answers render as formatted markdown (tables, code blocks)
  • Webhook Integration — press Enter in the popup to save translations to any REST API (flashcard apps, Notion, Anki, custom backends)
  • Multi-language — separate hotkeys per language, or auto-detect
  • Toggle design — one hotkey starts recording, same hotkey stops and processes. No daemon, no background service
  • Single file — one Python script, stdlib only. No pip, no venv, no Docker

How It Works

┌──────────┐    ┌─────────────┐    ┌──────────┐    ┌─────────┐
│ Mic      │───→│ pw-record   │───→│ Groq     │───→│ wtype   │
│ (hotkey) │    │ (PipeWire)  │    │ Whisper  │    │ (type)  │
└──────────┘    └─────────────┘    └──────────┘    └─────────┘

Translate mode:
┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────┐    ┌──────────┐
│ Mic      │───→│ Whisper  │───→│ LLM      │───→│ rofi │───→│ Webhook  │
│ (hotkey) │    │ (STT)    │    │ (translate)│   │ popup│    │ (save)   │
└──────────┘    └──────────┘    └──────────┘    └──────┘    └──────────┘

Ask mode (chat app):
┌──────────┐   ┌─────────┐   ┌───────────────────────────────┐   ┌──────────────┐
│ Mic      │──→│ Whisper │──→│ local chat server (127.0.0.1) │──→│ chrome --app │
│ (Alt+E/R)│   │ (STT)   │   │  LLM (ask_model) + history     │   │ chat window  │
└──────────┘   └─────────┘   └───────────────────────────────┘   └──────────────┘
       typed follow-ups ─────────────→ same server ──→ multi-turn conversation

Modes / arguments

Argument Behavior
en / ru Transcribe and type into the focused window (language hint)
translate Transcribe → translate (RU↔EN) → rofi popup → optional webhook save
ask-en / ask-ru Transcribe a question → create a new chat answered by ask_model and open the chat window. The suffix picks the answer language; the question may be spoken in any language.
show-last Open the chat app (history sidebar + most recent conversation). Bound to Alt+F.
serve Run the chat server (foreground). Normally lazy-started on demand; not bound to a key.

Chat app

Ask mode is a small local chat app, not a static overlay: a persistent Python-stdlib HTTP server on http://127.0.0.1:8717 serves a single-page UI with a left sidebar of all past conversations (click to open any, ✕ to delete) and typed follow-ups for multi-turn conversations (Enter to send). Voice (Alt+E/Alt+R) always starts a new conversation; the open window picks it up live via polling. The server is lazy-started on first use (murmur-type serve, spawned detached) and reused thereafter.

  • Storage: one JSON file per conversation in history/ (git-ignored — contains personal queries). {id, title, created, updated, lang, model, system_prompt, messages:[{role, content_md, images?, ts}]}.
  • Config (config.json): ask_provider (default openrouter), ask_api_key (falls back to translate_api_key, then api_key), ask_model (global default model), ask_max_tokens (output cap; default 0 = no cap, the provider's per-model max applies — set a positive value only to deliberately shorten answers), chat_system_prompt (global system-prompt override; empty = built-in).
  • Window: google-chrome --app=http://127.0.0.1:8717; floated at 900×680 by a niri window-rule matching app-id="127.0.0.1".
  • Safety: model-produced HTML is sanitized server-side (scripts/styles/inline event handlers/javascript: stripped); attached/generated images are accepted only as base64 data:image/... URLs; the UI builds the DOM via textContent + createContextualFragment rather than innerHTML.

Model picker (ask_models catalog)

The in-chat model dropdown (top-right) is driven by the ask_models list in config.json. Each entry is an object; the picker groups models into <optgroup> sections by category (order: flagship → general → regular → uncensored → image), shows a 👁 badge on vision models and a ★ on the global default, and offers a Custom… entry for ad-hoc ids. The chosen model is pinned per conversation (stored on the conversation; Custom… / picker changes PATCH it).

"ask_models": [
  { "path": "~anthropic/claude-opus-latest", "label": "claude-opus-latest", "category": "flagship", "vision": true,  "max_tokens": 128000 },
  { "path": "thedrummer/cydonia-24b-v4.1", "label": "cydonia-24b",    "category": "uncensored", "vision": false, "max_tokens": 131072 }
]

Legacy bare-string ids ("~anthropic/claude-opus-latest") are still accepted (treated as general, non-vision).

  • Analyze image (vision) — for models with "vision": true, attach images via the 📎 button or paste into the input; they're sent as multimodal content to the model.
  • Generate image — add a model with "category": "image" (implies "kind": "image"); requests then use OpenRouter's modalities: ["image","text"] and returned images render inline. Provide a real image-capable OpenRouter model id — none of the bundled text models generate images.

Rewind & regenerate

Hovering any message shows a small toolbar:

  • ⏪ Rewind (time-travel) — destructive: deletes that message and everything after it (with a confirm), so you can resume the conversation from an earlier point.
  • ↻ Regenerate (assistant messages) — re-runs the preceding turn with the model currently selected in the top picker and keeps the previous answer: a ‹ k/n › switcher appears to flip between answer variants. The active variant is what later turns use as context.

Backend routes: POST /api/conversations/<id>/truncate {index}, POST …/regenerate {index, model?}, POST …/variant {index, active}. Assistant messages are stored as {variants:[{content_md, images?, model, ts}], active} (legacy single-answer messages are auto-upgraded on first regenerate).

Screenshots

Voice Translate popup (rofi)

The translated word is highlighted with underline in each context sentence:

🇷🇺  выдержка

🇬🇧  endurance

1) His endurance was tested during the marathon.
    ↳ Его выдержка была проверена во время марафона.

2) The job requires both patience and endurance.
    ↳ Работа требует как терпения, так и выдержки.

3) She showed remarkable endurance under pressure.
    ↳ Она проявила замечательную выдержку под давлением.

⏎  Enter = save to vocabulary  |  Esc = dismiss

Requirements

  • Linux with Wayland (tested on niri + Arch Linux)
  • Python 3.10+ (stdlib only, no pip packages)
  • PipeWire — pw-record for microphone capture
  • wtype — types text into the focused Wayland window
  • rofi — popup for translate mode
  • notify-send — desktop notifications
  • Groq API key (free) — for Whisper speech-to-text and LLM translation

Install dependencies (Arch Linux)

sudo pacman -S python pipewire wtype rofi libnotify

Get a Groq API key

  1. Go to https://console.groq.com/keys
  2. Sign up (free, Google/GitHub login)
  3. Create an API key

Installation

# Clone the repo
git clone https://github.com/Skippia/murmur-type.git
cd murmur-type

# Run the installer
./install.sh

# Edit config with your API key
nano config.json

The installer:

  • Creates a symlink ~/.local/bin/murmur-type → murmur-type.py
  • Copies config.example.json → config.json (if not exists)
  • Checks that all dependencies are installed

Manual installation

# 1. Clone anywhere
git clone https://github.com/Skippia/murmur-type.git ~/murmur-type

# 2. Create config
cp config.example.json config.json
# Edit config.json — at minimum set "api_key"

# 3. Symlink to PATH
ln -s ~/murmur-type/murmur-type.py ~/.local/bin/murmur-type

# 4. Make sure ~/.local/bin is in your PATH

Configuration

Edit config.json:

{
  "provider": "groq",
  "api_key": "gsk_YOUR_KEY_HERE",
  "model": "whisper-large-v3",
  "language": "",
  "translate_model": "llama-3.3-70b-versatile",
  "app_url": "http://localhost:3009",
  "app_login": "",
  "app_password": "",
  "app_topic_id": ""
}

Required fields

Field Description
provider "groq" or "openrouter" — both hit an OpenAI-compatible /audio/transcriptions endpoint and run the same Whisper model. "openrouter-chat" is a fallback that feeds the audio to a chat model instead (for audio-capable LLMs, not Whisper). "assemblyai" uses the AssemblyAI Dictation API — see Switching the STT backend
api_key Key for the chosen provider — Groq (gsk_...) or OpenRouter (sk-or-v1-...)
model Whisper model: "whisper-large-v3" (best accuracy) or "whisper-large-v3-turbo" (faster)

Optional fields

Field Description
language Default language hint. Leave "" for auto-detect, or set "en", "ru", "uk", etc.
translate_model LLM for translation. Default: "llama-3.3-70b-versatile"
webhook Webhook config for saving translations (optional, see Webhook Integration)

Switching the STT backend

Two speech-to-text backends ship side by side. Both sets of settings live in the same config.json, so switching is a one-field edit and no key has to be re-pasted:

{ "provider": "openrouter" }   ← Whisper, OpenAI-compatible multipart
{ "provider": "assemblyai" }   ← AssemblyAI Dictation API

Only the en / ru / translate transcription step changes. The translation and chat modes keep going through translate_provider / ask_provider regardless.

Whisper (groq / openrouter) AssemblyAI (assemblyai)
Endpoint /v1/audio/transcriptions POST dictation.assemblyai.com/v1/transcribe/live
Round trips 1 1
Output verbatim transcript verbatim and an LLM-cleaned rewrite
Clip length unlimited 120 s per request
Languages ~99 32 (ru included)
Price ~$0.04–0.11 / hr $0.62 / hr

AssemblyAI fields

Field Description
assemblyai_api_key The preferred AssemblyAI key, tried first
assemblyai_api_keys Spare keys, tried in order when the preferred one is rejected — see Key rotation. Used alone if assemblyai_api_key is empty. When neither is set, api_key is used
assemblyai_languages Candidate codes used when the mode carries no language (i.e. translate). Default ["en","ru"]. Never sent empty: the service would silently default to English
assemblyai_cleanup true types the cleaned rewrite (filler gone, self-corrections resolved, punctuation applied); false types the verbatim transcript. The rewrite runs and is billed either way
assemblyai_cleanup_translate Cleanup in translate mode. Default false: the rewrite capitalises and punctuates, and "Собака." is not the flashcard you wanted
assemblyai_stt_prompt Context description, max 6000 chars — e.g. "A developer dictating a commit message."
assemblyai_llm_instruction Replaces the default cleanup task, max 2048 chars. Empty keeps the default
assemblyai_keyterms Names and jargon to bias transcription toward. Max 100 terms / 8000 chars; a longer list is truncated, not rejected

Recordings longer than 120 s fail before the request is sent, with the measured length in the message — the Dictation API caps one request there.

Key rotation

AssemblyAI has no balance endpoint, so "does this key still have money on it" is only ever answered by a request that has already been rejected. The pool turns that rejection into a switch to the next key instead of a lost utterance:

{
  "assemblyai_api_key": "the_one_you_top_up",
  "assemblyai_api_keys": ["spare_1", "spare_2", "spare_3"]
}

Keys are tried in order. A rejection advances to the next key only when the next key could plausibly do better:

Response Action
400 whose detail says the balance is negative next key, marked funds (24 h)
402 Payment Required next key, marked funds (24 h)
401 / 403 next key, marked auth (24 h)
429 that survived its Retry-After next key, marked rate (5 min)
400 malformed config, 413, 415 fail immediately — every key answers the same
5xx fail immediately — the service is down, not the key

Marks live in .run/assemblyai_marks.json and are keyed by a SHA-256 prefix, so the file is not a second copy of the keys. They are on disk rather than in memory because murmur-type is one process per dictation — an in-memory set would forget between the two halves of a single hotkey press.

Marks expire on their own, and if every key ends up marked the marks are dropped and the whole list is tried again: a balance can be topped up at any moment with nothing to tell us, and refusing until the TTL lapses would turn a five-minute top-up into a dead backend.

Keybindings

Add these to your compositor config. Examples below for different compositors:

niri (~/.config/niri/config.kdl)

binds {
    Mod+Shift+E  hotkey-overlay-title="Voice-to-text (English)"   { spawn "murmur-type" "en"; }
    Mod+Shift+R  hotkey-overlay-title="Voice-to-text (Russian)"   { spawn "murmur-type" "ru"; }
    Mod+Shift+A  hotkey-overlay-title="Voice translate (RU → EN)"  { spawn "murmur-type" "translate"; }
}

sway (~/.config/sway/config)

bindsym $mod+Shift+e exec murmur-type en
bindsym $mod+Shift+r exec murmur-type ru
bindsym $mod+Shift+a exec murmur-type translate

Hyprland (~/.config/hypr/hyprland.conf)

bind = $mainMod SHIFT, E, exec, murmur-type en
bind = $mainMod SHIFT, R, exec, murmur-type ru
bind = $mainMod SHIFT, A, exec, murmur-type translate

Usage

Voice → Type (English)

  1. Press Mod+Shift+E — notification "Recording (EN)..."
  2. Speak in English
  3. Press Mod+Shift+E again — notification "Processing..."
  4. Transcribed text is typed into the focused window

Voice → Type (Russian)

Same as above but with Mod+Shift+R.

Voice Translate (RU → EN)

  1. Press Mod+Shift+A — notification "Recording (RU → EN)..."
  2. Say a Russian word or phrase
  3. Press Mod+Shift+A again
  4. A rofi popup appears with:
    • The Russian word you said
    • English translation (bold)
    • 3 example sentences with the word underlined
    • Russian translation for each example
  5. Enter — saves as a vocabulary card (if app is configured)
  6. Escape — dismiss

VPN Split-Tunnel

If you use a VPN (e.g., Windscribe) that routes through datacenter IPs, Groq will block your requests with a 403 error. The included groq-route.sh script adds direct routes for Groq's Cloudflare IPs, bypassing the VPN tunnel.

# Install as a systemd service (persists across reboots)
sudo cp groq-route.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now groq-route.service

This only affects traffic to Groq's API IPs (172.64.149.20, 104.18.38.236). All other traffic continues through your VPN.

This no longer helps if your ISP is itself in a country Groq does not serve. In that case both paths are 403 with different bodies:

Path Response
Direct (blocked country) {"error":{"message":"Forbidden"}}
Through a datacenter/VPN IP {"error":{"message":"Access denied. Please check your network settings."}}

The fix is to switch provider to "openrouter" and keep model: "whisper-large-v3" — OpenRouter exposes the same Whisper model on the same OpenAI-compatible endpoint and has no such block. Note that those two Cloudflare IPs are anycast and shared with other zones, so leave the split-tunnel routes uninstalled when you are not using Groq.

File Structure

murmur-type/
├── murmur-type.py          # Main script (single file, stdlib only)
├── config.json            # Your config (gitignored)
├── config.example.json    # Config template
├── install.sh             # Installer script
├── groq-route.sh          # VPN split-tunnel route script
├── groq-route.service     # Systemd unit for persistent routes
├── .run/                  # Runtime data (gitignored, auto-created)
│   ├── recording.pid      # PID of pw-record process
│   ├── recording.wav      # Temporary audio file
│   ├── mode               # Current recording mode
│   └── app_token          # Cached auth token (if webhook uses auth)
└── README.md

Webhook Integration

When using the translate mode (Mod+Shift+A), pressing Enter in the rofi popup can send the word and translation to any HTTP endpoint. This lets you integrate with flashcard apps, Notion, Anki, Google Sheets, or any service with a REST API.

Set "webhook": null (or omit it) to disable — the rofi popup will still work, just without saving.

Basic webhook (no auth)

Send a POST request with the word and translation to any URL:

{
  "webhook": {
    "url": "https://your-app.com/api/words",
    "body": {
      "word": "{{word}}",
      "translation": "{{translation}}"
    }
  }
}

{{word}} and {{translation}} are placeholders — they get replaced with the actual values at runtime.

Webhook with static headers

If your API uses an API key or static token:

{
  "webhook": {
    "url": "https://your-app.com/api/words",
    "headers": {
      "X-Api-Key": "your-api-key",
      "Authorization": "Bearer your-static-token"
    },
    "body": {
      "word": "{{word}}",
      "translation": "{{translation}}"
    }
  }
}

Webhook with JWT login flow

If your API requires logging in first to get a JWT token:

{
  "webhook": {
    "url": "https://your-app.com/api/words",
    "body": {
      "topicId": "some-category-id",
      "word": "{{word}}",
      "translation": "{{translation}}"
    },
    "auth": {
      "url": "https://your-app.com/api/auth/login",
      "body": {
        "login": "your-username",
        "password": "your-password"
      },
      "token_path": "data.token"
    }
  }
}

How the auth flow works:

  1. On first request, murmur-type sends a POST to auth.url with auth.body
  2. Extracts the JWT token from the response using token_path (dot notation — e.g., "data.token" extracts response.data.token)
  3. Adds Authorization: Bearer <token> to the webhook request
  4. Caches the token in .run/app_token so subsequent calls skip the login
  5. If the webhook returns 401 (token expired), automatically re-authenticates and retries once

Custom body fields

The body object can contain any structure your API expects. Only {{word}} and {{translation}} are replaced — everything else is sent as-is:

{
  "webhook": {
    "url": "https://api.notion.com/v1/pages",
    "headers": {
      "Notion-Version": "2022-06-28",
      "Authorization": "Bearer ntn_your_token"
    },
    "body": {
      "parent": { "database_id": "abc123" },
      "properties": {
        "Word": { "title": [{ "text": { "content": "{{word}}" } }] },
        "Translation": { "rich_text": [{ "text": { "content": "{{translation}}" } }] }
      }
    }
  }
}

Troubleshooting

403 error from Groq

Your IP is blocked — either as a datacenter/VPN exit or by country. The response body tells you which; see VPN Split-Tunnel. Quick check that isolates it from an API-key problem:

curl -s https://api.groq.com/openai/v1/models   # no auth header

If that already returns 403, the key is irrelevant — it is a network-level block. Switch provider to "openrouter".

"Recording too short"

You pressed the hotkey twice too fast. Hold for at least 1 second.

No sound captured

Check that PipeWire is running and your mic is the default source:

pw-record --list-targets

wtype doesn't work

Make sure you're on Wayland (not XWayland). Some apps (e.g., Electron with --disable-gpu) may not receive wtype input.

License

MIT

About

Murmur and it types. Voice-to-text & instant translation for Linux/Wayland. Whisper-powered, zero dependencies, single Python file.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages