---
title: "Getting Push-to-Talk Speech-to-Text Working on niri"
description: "Setting up Handy on Fedora with niri. Three bugs that all looked like each other, and why the documented way to control it kills the app."
date: 2026-08-01
tags:
  - linux
  - wayland
  - niri
  - fedora
---

**Short answer:** if you're running Handy on a Wayland compositor and it isn't pasting, check three things in this order. One, don't use `pkill -USR2 handy` to toggle it. The README documents that, but the 0.9.4 AppImage doesn't implement the signal handler, so the default terminate disposition applies and the signal just kills the app. Use `handy --toggle-transcription` instead. Two, the recording overlay is a normal window, not a layer surface, so it takes focus and Handy pastes into itself. Fix it with a `window-rule` and `open-focused false`. Three, if you run anything from a systemd user service, use absolute paths, because the minimal PATH there does not include `~/.local/bin`.

The rest of this is how I found all three the slow way.

---

## The Setup

I wanted speech-to-text. Not the cloud kind. Something that runs locally, transcribes what I say, and drops the text wherever my cursor is.

Handy does this. It's a Tauri app, MIT licensed, runs Whisper or Parakeet models entirely offline. I grabbed the AppImage.

My machine:

- Fedora 43
- niri (scrolling Wayland compositor)
- RTX 4060 laptop, NVIDIA proprietary
- Parakeet V3 for transcription, which is CPU-only and quick

The README has a Hyprland section. I do have a `~/.config/hypr/hyprland.conf` sitting in my dotfiles, so I almost followed it. Then I checked what was actually running:

```bash
echo $HYPRLAND_INSTANCE_SIGNATURE   # unset
echo $NIRI_SOCKET                   # /run/user/1000/niri.wayland-1.3803.sock
```

Right. I moved to niri weeks ago and never deleted the old Hyprland config. Everything below goes in `~/.config/niri/config.kdl` instead.

---

## The First Problem: No Global Hotkeys

On Wayland, apps can't grab global keyboard shortcuts. There's no X11-style "give me every keypress" API, which is a good thing for security and an annoying thing right now. Handy has a settings panel where you configure `ctrl+space` as your shortcut, and on Wayland that setting does nothing at all.

So the compositor has to own the keybind and tell Handy what to do. The README suggests signals:

```ini
bindsym $mod+o exec pkill -USR2 -n handy
```

Sensible. Compositor owns the key, app gets a signal. Remember this, it comes back later.

---

## The Second Problem: Hold to Talk

I didn't want toggle mode. Toggle means press once to start, press again to stop, and if you forget the second press you're recording your room until the model unloads. I wanted push-to-talk. Hold the keys, speak, release.

That needs a key release event. niri gives you press events.

I checked whether it had a release property:

```kdl
binds {
    Mod+Shift+Space repeat=false { spawn-sh "echo down"; }
    Mod+Shift+Space on-release=true { spawn-sh "echo up"; }
}
```

```
× unexpected property
× duplicate keybind later defined here
```

Two errors. `on-release` isn't a thing in niri 26.04, and duplicate binds are rejected anyway. There's no `niri msg` subcommand for it either. Push-to-talk is not possible with niri keybinds. Full stop.

So I went underneath the compositor.

`/dev/input/event*` is where the kernel exposes raw input. If you're in the `input` group you can read those devices directly, and you get both press and release events, because that's what the hardware actually sends. The compositor is just one consumer of that stream.

I was already in the group:

```bash
id -nG
# xevrion wheel input libvirt docker wireshark plugdev ollama mock
```

So the plan became: small Python daemon reads the keyboards via evdev, watches for Ctrl+Space down and up, tells Handy to start and stop.

```bash
sudo dnf install python3-evdev
```

The script lives at `~/.local/bin/handy-ptt`. The core of it:

```python
TRIGGER = ecodes.KEY_SPACE
MODIFIERS = {
    frozenset({ecodes.KEY_LEFTCTRL}),
    frozenset({ecodes.KEY_RIGHTCTRL}),
}

# ...

if value == KEY_DOWN and not recording:
    if any(combo <= held for combo in MODIFIERS):
        recording = True
        toggle()
elif value == KEY_UP and recording:
    recording = False
    toggle()
```

It watches every device that reports both Space and Ctrl, so my laptop keyboard and my external one both work without hardcoding a device path. It also handles the case where you release the modifier before the trigger key, which otherwise leaves you recording forever.

Then a systemd user service so it starts with the session:

```ini
[Unit]
Description=Push-to-talk key watcher for Handy
After=graphical-session.target
PartOf=graphical-session.target

[Service]
Type=simple
ExecStart=/home/xevrion/.local/bin/handy-ptt
Restart=on-failure
RestartSec=3

[Install]
WantedBy=graphical-session.target
```

```bash
systemctl --user enable --now handy-ptt.service
```

Keys detected. Daemon running. And Handy died.

---

## The Bug That Looked Like Three Bugs

Here's where I lost the afternoon.

The log ended like this, every time:

```
[INFO] Using paste method: Direct, delay before: 60ms, delay after: 60ms
[INFO] Using user-specified wtype
```

And then nothing. No "pasted successfully". No error. Just the end of the file.

That reads like a `wtype` problem. `wtype` is the Wayland tool that types text into the focused window, and the last thing in the log before silence is Handy saying it's about to use `wtype`. So obviously `wtype` is hanging.

It wasn't. `wtype "test"` worked fine on its own. Worked fine with modifiers held down. Worked fine in every variation I threw at it.

I also found this in the logs:

```
[ERROR] Failed to update tray icon 'resources/recording.png': Software caused connection abort (os error 103)
```

Error 103 on a Wayland client usually means the compositor closed the socket. So now I'm thinking protocol error, and I run with `WAYLAND_DEBUG=1` and read a few thousand lines of protocol traffic. No protocol error. The connection just ends.

I tried `--no-tray`. Same crash, and the tray error still appeared, which should have told me something earlier than it did.

Then I stopped assuming and just tested the smallest possible thing:

```bash
handy --start-hidden &
sleep 14
pkill -USR2 -x handy
sleep 6
pgrep -x handy || echo "DIED"
```

```
DIED
```

One signal. Not a press-release pair, not a full cycle. A single SIGUSR2, and the app is gone before it ever starts recording.

The README documents SIGUSR2 as the way to toggle transcription. This build doesn't implement the handler. And when a process has no handler for SIGUSR2, the kernel's default action for that signal is to terminate it. So the documented control mechanism was just killing the app.

Everything downstream made sense after that. The log ended mid-paste because the process died mid-paste. The tray error was the app tearing down, not the cause of anything. `wtype` was innocent the whole time.

The fix is the CLI flag, which goes through Tauri's single-instance plugin and actually works:

```python
subprocess.run([HANDY, "--toggle-transcription"], timeout=10)
```

<span class="blue">When a log ends abruptly instead of showing an error, suspect the process, not the last thing it logged.</span>

---

## The Third Problem: Focus

Now it recorded. Now it transcribed. And then it pasted the text into itself.

Handy shows a small overlay while recording. That overlay was taking keyboard focus, so by the time transcription finished, the app I'd been typing in wasn't focused anymore.

I went and read niri's source and issue tracker for this, because I assumed it was a layer-shell problem. Handy links `gtk-layer-shell`, and layer surfaces have a `keyboard-interactivity` property with values none, on-demand, and exclusive.

Turns out that's a dead end, and it's worth writing down. `keyboard-interactivity` is a **client** request. niri reads it and honors it, but never overrides it. There is no layer rule for focus. niri has exactly eight layer-rule properties and not one of them touches keyboard input. YaLTeR [declined to add an override](https://github.com/niri-wm/niri/issues/3050), pointing at [an earlier issue](https://github.com/niri-wm/niri/issues/641) where launchers like lxqt-runner depend on on-demand surfaces getting focus, and punted the whole thing upstream to wlr-protocols.

So if it had been a layer surface, I'd have been stuck.

It isn't one:

```bash
niri msg windows
```

```
Window ID 51: (focused)
  Title: "Recording"
  App ID: "Handy"
  Is floating: yes
  PID: 4156
```

That's a plain `xdg-toplevel`. gtk-layer-shell isn't engaging in this AppImage, so the overlay falls back to being a regular window, and niri focuses regular windows.

Regular windows are easy. That's just a window rule:

```kdl
window-rule {
    match app-id=r#"^Handy$"# title="^Recording$"
    open-floating true
    open-focused false
    focus-ring { off; }
    shadow { off; }
}
```

Tested it with Discord focused:

```
focus before:            discord
focus DURING recording:  discord
focus AFTER:             discord
```

Visible overlay, focus never moves. Which is exactly what you want from a recording indicator.

I'd checked `niri msg layers` earlier and seen no Handy surface, and concluded there was no focus problem at all. I was wrong, and the reason is dumb: I ran that check while Handy was idle, so the overlay window didn't exist yet. Check the state while the thing is happening, not before.

---

## The Fourth Problem, Which Was Really the First

Everything was correct now and it still didn't work.

```
Aug 01 14:50:03 fedora handy-ptt[214465]: press -> record
Aug 01 14:50:03 fedora handy-ptt[214465]: failed to toggle handy: [Errno 2] No such file or directory: 'handy'
```

The daemon saw my keys perfectly. It just couldn't find the binary.

systemd user services run with a minimal PATH. `~/.local/bin` is not in it. I'd tested every command from my interactive shell, where it obviously is in PATH, so the daemon was the only context where this broke, and it broke silently for as long as I wasn't reading its journal.

```python
HANDY = "/home/xevrion/.local/bin/handy"
```

That was it. That was the last one.

---

## What It Looks Like Now

Hold Ctrl+Space. Talk. Release. Text appears wherever the cursor is.

The pieces:

```
~/.local/bin/handy                       symlink to the AppImage
~/.local/bin/handy-ptt                   evdev key watcher
~/.config/systemd/user/handy-ptt.service starts it with the session
~/.config/niri/config.kdl                window-rule + cancel bind
```

Settings worth changing on Linux, in Handy's own config:

- `typing_tool: wtype`, since the enigo fallback is unreliable on Wayland
- `start_hidden: true`, so it goes to the tray on login instead of popping a window
- Push-to-talk off in Handy itself. The daemon sends two discrete toggles, so the app should be in toggle mode. Its internal PTT setting is for hotkeys that don't work here anyway.

The model is Parakeet V3, CPU only, and it's quicker than I expected. From the logs:

```
Live preview finalized in 0.63s model compute for 5.79s streamed audio (9.13x real-time)
```

Nine times real-time on CPU, no GPU involved.

One tradeoff I knowingly accepted. Because the daemon reads keys below the compositor, Ctrl+Space still reaches the focused app. In Discord that's nothing. In an editor it may also fire autocomplete, and in some setups it's the IME toggle. A Super-based combo would be swallowed by niri and avoid that entirely. I picked Ctrl+Space because it's what my hands already do.

---

## What I Actually Learned

Three bugs, and every one of them presented as a different bug than it was.

The crash looked like a `wtype` failure because `wtype` was the last thing in the log. The focus problem looked solved because I checked at the wrong moment. The PATH issue looked like the keybind not registering, when the keybind was fine and had been fine for hours.

The common thread is that I kept trusting what the symptom pointed at instead of testing the smallest thing that could confirm it. Sending exactly one signal and watching whether the process survived took about fifteen seconds and would have saved most of an afternoon.

Also: read the running config, not the config that's sitting in your dotfiles. I nearly wrote all of this into a Hyprland file for a compositor I stopped using weeks ago.
