{"id":635,"date":"2026-02-16T16:02:52","date_gmt":"2026-02-17T00:02:52","guid":{"rendered":"https:\/\/nramkumar.org\/tech\/?p=635"},"modified":"2026-05-13T16:55:47","modified_gmt":"2026-05-14T00:55:47","slug":"voice-to-text-poor-mans-wisprflow-on-linux","status":"publish","type":"post","link":"https:\/\/nramkumar.org\/tech\/blog\/2026\/02\/16\/voice-to-text-poor-mans-wisprflow-on-linux\/","title":{"rendered":"Speech To Text on Linux &#8211; Local and High Performance Wisprflow like setup on Linux"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Fixes After Ubuntu 26.04 Upgrade<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After upgrading to Ubuntu 26.04 (Plasma 6.6.4), two bugs broke the tool:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>The ydotool package in 26.04 renamed the &#8211;delay flag to &#8211;key-delay. The old flag was silently ignored, causing ydotool to treat the value 2 as a literal string to type. \n<ul class=\"wp-block-list\">\n<li>Fix: <code>subprocess.run([\"ydotool\", \"type\", \"--key-delay\", \"2\", text + \" \"], env=env, check=True)<\/code><\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>Use the English-only model &#8211; changed MODEL_SIZE from &#8220;base&#8221; to &#8220;base.en&#8221;<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Microphone Gain Tuning<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The C930e has two independent gain stages that are easy to confuse:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>PipeWire\/PulseAudio layer \u2014 what pavucontrol and KDE audio settings control<\/li>\n\n\n\n<li>ALSA hardware layer \u2014 the USB device&#8217;s own gain register, invisible to PipeWire The hardware gain defaults to 100% (50dB), which saturates the signal before PipeWire even sees it. Reduce it with: <code>amixer -c 1 set 'Mic' 80%<\/code> . Find the right card number first with <code>arecord -l<\/code>. <\/li>\n\n\n\n<li>To persist across reboots: <code>sudo alsactl store<\/code> <\/li>\n\n\n\n<li>To diagnose clipping, use <code>sox<\/code> on the recorded file: <code>sox \/tmp\/whisper_audio.wav -n stat<\/code> Aim for a maximum amplitude of 0.4\u20130.7. Hitting 1.0 means hardware clipping regardless of software volume settings.<\/li>\n\n\n\n<li>Best approach to debugging the issue is listening to the recorded file to identify clipping or distortion yourself, then tuning the gain till it goes away.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Recently I started experiencing pain at the dip joint of my ring finger. That prompted me to look into how  I can use voice more instead of typing everything on my computer. Especially with AI use, the typing has  increased a lot. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There are a lot of great solutions that exist for macOS, Windows, iOS and Android, but for Linux,  I wasn&#8217;t able to find anything that worked as easily, so I&#8217;m documenting my setup for others who are on Linux and want to use Voice to Text better.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note that my specific setup is Wayland and KDE, however, it can be adopted to non-Wayland  and other distributions and desktops quite easily. Because it uses ydotool, it is not dependent on Wayland or KDE for text injection and should work in other desktop environments  outside of Wayland and KDE reliably as well.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is a high level flow diagram of how the system works:<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"2560\" height=\"1857\" src=\"https:\/\/nramkumar.org\/tech\/wp-content\/uploads\/2026\/02\/output-2-scaled.png\" alt=\"\" class=\"wp-image-643\" srcset=\"https:\/\/nramkumar.org\/tech\/wp-content\/uploads\/2026\/02\/output-2-scaled.png 2560w, https:\/\/nramkumar.org\/tech\/wp-content\/uploads\/2026\/02\/output-2-300x218.png 300w, https:\/\/nramkumar.org\/tech\/wp-content\/uploads\/2026\/02\/output-2-1024x743.png 1024w, https:\/\/nramkumar.org\/tech\/wp-content\/uploads\/2026\/02\/output-2-768x557.png 768w, https:\/\/nramkumar.org\/tech\/wp-content\/uploads\/2026\/02\/output-2-1536x1114.png 1536w, https:\/\/nramkumar.org\/tech\/wp-content\/uploads\/2026\/02\/output-2-2048x1485.png 2048w\" sizes=\"auto, (max-width: 2560px) 100vw, 2560px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Here are the setup instructions for the setup I am using:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Hardware &#8211; for microphone, I am using a Logitech C930E webcam that I had lying around. This was bought during the pandemic and hasn&#8217;t been used at all. Turns out it has a great microphone. You can also use a BT headset, but this works well for me.<\/li>\n\n\n\n<li>Select the microphone as your input source, use the digital stereo input option if you have multiple options.<\/li>\n\n\n\n<li>Install the necessary components:\n<ul class=\"wp-block-list\">\n<li>On Wayland, xdotool or trying to using wl-copy and then simulating ctrl+v to inject text into the active window does not work reliably. ydotool seems to be the most reliable way to inject text into the window under focus. ydotool essentially fakes\/mimics an actual input source (like a keyboard).<\/li>\n\n\n\n<li>You will need arecord or ffmpeg for recording the stereo input to a wav file. Install via alsa-utils. You also need libnotify-bin (this shows a toast for recording\/transcribing which is helpful to keep track of what&#8217;s going on).<\/li>\n\n\n\n<li>sudo apt install ydotool ydotoold<\/li>\n\n\n\n<li>Since faster-whisper relies on ffmpeg, you will need a working install of ffmpeg<\/li>\n\n\n\n<li>Create a python venv, we will use a python script that will do the capture + transcribe and inject into the focus window.<\/li>\n\n\n\n<li>In the venv, install these: pip install faster-whisper<\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>Create the <a href=\"https:\/\/github.com\/ram-nat\/scripts\/blob\/main\/whisper_toggle.py\">whisper_toggle python script <\/a>below<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-code\"><code>import os\nimport sys\nimport subprocess\nimport time\nimport signal\nfrom faster_whisper import WhisperModel\n\n# --- CONFIGURATION ---\nMODEL_SIZE = \"base\"\nDEVICE = \"cpu\"\nCOMPUTE_TYPE = \"int8\"\nLOCK_FILE = \"\/tmp\/whisper_lock\"\nAUDIO_FILE = \"\/tmp\/whisper_audio.wav\"\n\ndef notify(message):\n    print(f\"&#x1f514; {message}\")\n    subprocess.run(&#91;\"notify-send\", \"-t\", \"1000\", \"Whisper\", message])\n\ndef start_recording():\n    # 1. Create lock\n    with open(LOCK_FILE, \"w\") as f:\n        f.write(str(os.getpid()))\n    \n    notify(\"Recording... (Press again to stop)\")\n    \n    # 2. Record using arecord\n    # -q: Quiet mode\n    cmd = &#91;\"arecord\", \"-f\", \"S16_LE\", \"-c\", \"1\", \"-r\", \"16000\", \"-q\", AUDIO_FILE]\n    \n    try:\n        # We use Popen so we can wait specifically for this process\n        process = subprocess.Popen(cmd)\n        process.wait()\n    except KeyboardInterrupt:\n        pass\n\ndef stop_and_transcribe():\n    # 1. STOPPING\n    # Give the OS a moment to flush the WAV header to disk\n    time.sleep(0.5)\n    \n    if not os.path.exists(AUDIO_FILE):\n        notify(\"&#x274c; Error: Audio file missing.\")\n        return\n\n    vocab_path = os.path.expanduser(\"~\/whisper-tool\/vocab.txt\")\n    rams_vocab = \"\"\n    if os.path.exists(vocab_path):\n        with open(vocab_path, \"r\") as f:\n            # Join lines and remove extra whitespace to create a clean hint string\n            rams_vocab = \", \".join(&#91;line.strip() for line in f if line.strip()])    \n\n    notify(\"Transcribing...\")\n\n    try:\n        # 2. TRANSCRIBING\n        # We load the model fresh each time (slower start, but saves RAM when idle)\n        model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE)\n        \n        segments, _ = model.transcribe(\n            AUDIO_FILE, \n            beam_size=1,\n            language=\"en\",                 # &lt;--- FORCE ENGLISH\n            vad_filter=True,               # &lt;--- IGNORE SILENCE\n            initial_prompt=rams_vocab,       # &lt;--- VOCAB HINTS\n            condition_on_previous_text=False\n        )\n        \n        text = \" \".join(&#91;segment.text for segment in segments]).strip()\n        \n        if text:\n            print(f\"&#x1f4dd; RESULT: {text}\")\n            # We explicitly pass the YDOTOOL_SOCKET in case the hotkey environment lacks it\n            env = os.environ.copy()\n            env&#91;\"YDOTOOL_SOCKET\"] = f\"{os.environ&#91;'HOME']}\/.ydotool_socket\"\n            \n           \n            # Small delay for keyboard release\n            time.sleep(0.1)\n            \n            # Paste using ydotool\n            try:\n                subprocess.run(&#91;\"ydotool\", \"type\", \"--delay\", \"2\", text + \" \"], env=env, check=True)\n            except subprocess.CalledProcessError as e:\n                print(f\"&#x274c; ydotool type failed: {e}\")            \n        else:\n            notify(\"&#x26a0;&#xfe0f; No speech detected.\")\n            \n    except Exception as e:\n        notify(f\"&#x274c; Error: {e}\")\n\ndef main():\n    if os.path.exists(LOCK_FILE):\n        # --- STOP SIGNAL ---\n        print(\"&#x1f6d1; Stop signal received. Finishing recording...\")\n        \n        # Send SIGINT (Ctrl+C) to arecord to make it save the file properly\n        os.system(\"pkill -SIGINT arecord\")\n        \n        # Clean up lock\n        if os.path.exists(LOCK_FILE):\n            os.remove(LOCK_FILE)\n    else:\n        # --- START SIGNAL ---\n        try:\n            start_recording()\n            # Script pauses here until arecord is killed by the second instance\n            \n            # Once killed, we proceed:\n            stop_and_transcribe()\n        finally:\n            if os.path.exists(LOCK_FILE):\n                os.remove(LOCK_FILE)\n\nif __name__ == \"__main__\":\n    main()\n<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>You need to add yourself to the input group and create a udev rule that allows access to the \/dev\/uinput device (used by ydotool) to the input group:<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo usermod -aG input \"$USER\"\necho 'KERNEL==\"uinput\", GROUP=\"input\", MODE=\"0660\", OPTIONS+=\"static_node=uinput\"' \\\n  | sudo tee \/etc\/udev\/rules.d\/80-uinput.rules &gt;\/dev\/null\nsudo udevadm control --reload-rules &amp;&amp; sudo udevadm trigger<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Log out and log back in (so groups membership is updated)<\/li>\n\n\n\n<li>Create a user-level ydotoold service so the daemon is started automatically when you log-in<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-code\"><code>cat &gt; ~\/.config\/systemd\/user\/ydotoold.service\n&#91;Unit]\nDescription=ydotoold (uinput) daemon\n\n&#91;Service]\nType=simple\nExecStart=\/usr\/bin\/ydotoold --socket-path=%h\/.ydotool_socket --socket-own=%U:%G\nRestart=on-failure\n\n&#91;Install]\nWantedBy=default.target\nCtrl-D\n\nsystemctl --user daemon-reload\nsystemctl --user enable --now ydotoold.service<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Make sure the daemon is running successfully.<\/li>\n\n\n\n<li>Test the python script &#8211; run in one terminal, start speaking, run in another terminal. If it all works, you should see the text of what you spoke in the second terminal.<\/li>\n\n\n\n<li>Map to a hot-key with KDE shortcut &#8211; I have mapped mine to ctrl+space.\n<ul class=\"wp-block-list\">\n<li>Add Command<\/li>\n\n\n\n<li>&lt;full venv path&gt;\/python3 &lt;path&gt;\/whisper_toggle.py<\/li>\n\n\n\n<li>Select your favored keyboard shortcut<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Overall, this works surprisingly well, although it took a little bit of time and effort to set up.  I really like how well it is working. One downside is when you&#8217;re actually using it to issue commands,  it really does not work well. Those you probably have to type, but for any dictation like interface,  this is pretty good. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For injecting specific words like names of people or other uncommon words or spellings  that you use, you can populate your vocabulary or text and the script will automatically  inject that into your prompt. This seems to work quite well for especially names of  people when I&#8217;m texting them from my computer using WhatsApp. <\/p>\n","protected":false},"excerpt":{"rendered":"<p>Fixes After Ubuntu 26.04 Upgrade After upgrading to Ubuntu 26.04 (Plasma 6.6.4), two bugs broke the tool: Microphone Gain Tuning The C930e has two independent gain stages that are easy to confuse: Recently I started experiencing pain at the dip joint of my ring finger. That prompted me to look into how I can use&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-635","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts\/635","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/comments?post=635"}],"version-history":[{"count":4,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts\/635\/revisions"}],"predecessor-version":[{"id":703,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts\/635\/revisions\/703"}],"wp:attachment":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/media?parent=635"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/categories?post=635"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/tags?post=635"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}