Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

KeyShot

A lightweight Linux background daemon, written in Go, that turns global keyboard events into real-time gun sound effects.

Character keys fire single gunshots, Space racks a shotgun, Enter fires a pistol, Backspace reloads, Escape draws a knife, and Delete empties a magazine. Every category is a configurable sound pool.

KeyShot opens the keyboard read-only and never grabs it, so it cannot swallow, delay or alter your keystrokes. It is purely an observer.


Requirements

  • Linux (tested on Ubuntu, kernel 6.8)
  • Go 1.21 or newer
  • PipeWire or PulseAudio
  • Membership of the input group (see below)

No ffmpeg and no system audio libraries are required. MP3 decoding, resampling and WAV conversion are all pure Go, and audio output speaks the PulseAudio protocol directly. The project builds with CGO_ENABLED=0; a C compiler is needed only to run the tests under -race.


Installation

git clone https://github.com/Ahmad-code077/keyboard-gun.git
cd keyboard-gun
go build -o bin/keyshot ./cmd/keyshot

Keyboard permission (one-time setup)

KeyShot observes raw key events from /dev/input/event*, which is owned by the input group. Add yourself to it once:

sudo usermod -aG input $USER

Then log out of your desktop session completely and log back in.

A full logout/login is required — this is not optional.

usermod only writes to the group database. Group membership is attached to a session when it is created, so nothing already running picks it up. That includes your systemd --user manager, which is what starts KeyShot.

newgrp input and sg input change only the single shell you run them in, and systemctl --user daemon-reexec keeps the manager's existing credentials. Neither will make the service work. A reboot works too.

Confirm it took effect after logging back in:

id -nG | tr ' ' '\n' | grep input      # must print: input

This is the only step that needs sudo. KeyShot itself always runs as your own user — never as root. See Security for why this access is needed.


Verify it works

1. Keyboard detection

./bin/keyshot --list-devices
/dev/input/event4     readable            AT Translated Set 2 keyboard

If it says PERMISSION DENIED, the input group step above has not taken effect yet.

2. Audio

./bin/keyshot --test-audio char           # one category
./bin/keyshot --test-audio all            # every category

This needs the speakers but not the keyboard, so it works before the permission step. It exits non-zero if the audio stream fails, so a silent device is reported rather than mistaken for working playback.

3. Run it

./bin/keyshot --config config/config.yaml
KeyShot ready (config: config/config.yaml)
  listening: /dev/input/event4    AT Translated Set 2 keyboard
  categories: char(18), space(2), enter(1), backspace(1), delete(1), escape(1)
  max voices: 32, volume: 0.75, latency: 25ms

Type anywhere. Stop with Ctrl-C.


Run automatically in the background

KeyShot installs as a systemd user service — never a system one, because it needs your login session's audio server and your own user's permissions.

./scripts/install-service.sh

The script builds the binary if needed, installs the unit pointed at this checkout, enables it, starts it, and warns you if you are not yet in the input group.

It starts on its own from now on

Once installed and enabled, KeyShot starts automatically every time you log in to your desktop, and stops when you log out. There is nothing to run by hand and nothing to add to a startup-applications list.

enable is what makes that happen, and the install script already did it. To confirm:

systemctl --user is-enabled keyshot    # enabled
systemctl --user is-active keyshot     # active

Manual control is for troubleshooting only

You do not need these for normal use — automatic startup is already handled. Reach for them when something is wrong, or when you want KeyShot temporarily out of the way:

systemctl --user restart keyshot   # after editing config or adding sounds
systemctl --user stop keyshot      # silence it until next login
systemctl --user start keyshot     # start it again now

stop is temporary — it does not survive a logout, and KeyShot will be back at your next login. To stop it starting automatically at all:

systemctl --user disable --now keyshot

Checking status and logs

systemctl --user status keyshot        # running state, PID, recent log lines
journalctl --user -u keyshot -f        # follow the log live
journalctl --user -u keyshot -n 50 --no-pager   # last 50 lines
journalctl --user -u keyshot -b        # everything since this boot

A healthy start looks like this:

KeyShot ready (config: /home/you/keyboard-gun/config/config.yaml)
  listening: /dev/input/event4    AT Translated Set 2 keyboard
  categories: char(18), space(2), enter(1), backspace(1), delete(1), escape(1)
  max voices: 32, volume: 0.75, latency: 25ms

If it fails, it retries a few times and then gives up rather than looping for ever (StartLimitBurst=3 within 60 seconds), so a permanent fault such as a permission problem is reported once and stays visible in status instead of being buried under thousands of restart entries. After fixing the cause:

systemctl --user reset-failed keyshot
systemctl --user start keyshot

Command reference

Command Purpose
keyshot --config config/config.yaml Run the daemon
keyshot --test-audio <category> Play one category and exit
keyshot --test-audio all Play every category and exit
keyshot --list-devices Show detected keyboards and whether they are readable
keyshot --quiet Only report errors
./scripts/install-service.sh Install and start the user service
./scripts/prepare-audio.sh Rebuild all runtime assets from assets/source/
go run ./cmd/prepare-audio -add <file> -category <name> Add one of your own sounds (details)
go run ./cmd/inspect-audio -dir assets/source Report format and character of audio files

Configuration

config/config.yaml. Paths in it are resolved relative to the file itself, so the daemon behaves the same whatever its working directory.

audio:
  volume: 0.75
  max_voices: 32
  latency_ms: 25        # raise if you hear crackling under load

keyboard:
  ignore_repeat: true   # holding a key does not machine-gun
  play_on_release: false
  devices: []           # empty = auto-detect

default_category: char

sounds:
  char:
    directory: ../assets/sounds/char
    mode: random        # or "sequential"
    avoid_repeat: true  # never play the same sound twice running
  shift:
    mute: true

keys: {}

Individual keys override their category:

keys:
  Enter:
    sound: ../assets/sounds/enter/pistol-01.wav   # one exact file
  F1:
    directory: ../assets/sounds/space             # a pool of its own
  Tab:
    mute: true                                    # silent

Resolution order:

individual key override -> the key's category -> default_category pool -> silence

Shift, Ctrl and Alt are muted by default: Shift fires on every capital letter and would double up the shot during ordinary typing.

Choosing which keyboard to listen to

By default KeyShot finds keyboards itself. To listen to one specific device, pin it:

keyboard:
  devices: ["/dev/input/by-path/platform-i8042-serio-0-event-kbd"]

Get the path from --list-devices, which prints a stable alias when one exists:

$ ./bin/keyshot --list-devices
/dev/input/event4     readable            AT Translated Set 2 keyboard
  stable path (pin this in keyboard.devices):
    /dev/input/by-path/platform-i8042-serio-0-event-kbd

Prefer the stable path over /dev/input/eventN. Event numbers are assigned in discovery order and can change when you reboot or plug something in; by-id and by-path symlinks do not. KeyShot resolves the symlink at startup and reports the real node it points to.

A pinned path that cannot be resolved is a startup error, never a silent fallback to auto-detection — so a device that has been renamed or unplugged is reported rather than quietly ignored.

Auto-detection deliberately skips devices that carry the kbd handler but are not keyboards, such as power buttons, laptop hotkey blocks, and Bluetooth headset media controls.

Sound folders

assets/source/    original downloads, read-only input to the prepare step
assets/sounds/    runtime assets, loaded by the daemon
  char/  space/  enter/  backspace/  delete/  escape/

Runtime assets are 48 kHz 16-bit stereo WAV. To rebuild them after changing assets/source/:

./scripts/prepare-audio.sh

Preparation trims leading silence so the shot is heard immediately, resamples everything to one rate, matches loudness across each pool, and applies fades so a voice can be cut without clicking. Character sounds are held to about 450 ms: a gunshot holds full level for roughly 200 ms, so anything shorter sounds like a click rather than a shot.


Adding your own sounds

You can replace or extend any category with your own audio — memes, voice clips, movie quotes, different weapons. No Go code needs editing.

What happens to your file

KeyShot plays from RAM at a single fixed format, so a file cannot simply be dropped in as-is. prepare-audio -add does the conversion for you:

your file (MP3 or WAV, any rate)
    |
    v
decode (pure Go, no ffmpeg)
    |
    v
trim silence at the start  ->  so the sound is heard the instant you press
convert to stereo
resample to 48 kHz
shorten (char only)        ->  so it does not smear while typing
match loudness             ->  so it sits with the other sounds
fade in/out                ->  so cutting it never clicks
    |
    v
assets/sounds/<category>/<name>-NN.wav

Your original is never modified, and existing sounds are never overwritten — each new file gets the next free number.

1. Add a sound to an existing category

go run ./cmd/prepare-audio -add ~/Downloads/bruh.mp3 -category char
Added assets/sounds/char/bruh-01.wav
  source:   /home/you/Downloads/bruh.mp3
  duration: 450ms
  format:   PCM WAV, 16-bit, 48000 Hz, 2 ch

Now every letter key randomly picks from that pool, including your sound.

Valid categories: char, space, enter, backspace, delete, escape, or any new name you invent (see below).

Useful flags:

Flag Purpose
-category <name> Which pool to add to (default char)
-name <base> Output name; defaults to a cleaned-up version of the file name
-keep-length Keep the full recording instead of shortening it for typing

char sounds are trimmed to about 450 ms by default, because they fire on every keystroke. Space, Enter and the rest keep their full length.

2. Put a sound on one specific key

Give a single key its own sound — this is how you build a meme hotkey:

go run ./cmd/prepare-audio -add ~/Downloads/airhorn.mp3 -category memes

Then in config/config.yaml:

keys:
  F1:
    sound: ../assets/sounds/memes/airhorn-01.wav

Or point a key at a whole folder, so it picks a random one each press:

keys:
  F1:
    directory: ../assets/sounds/memes

An individual key override always beats that key's category.

3. Make a whole new category

Add several sounds to a new folder:

go run ./cmd/prepare-audio -add ~/Downloads/vine-boom.mp3   -category memes
go run ./cmd/prepare-audio -add ~/Downloads/bruh.mp3        -category memes
go run ./cmd/prepare-audio -add ~/Downloads/windows-xp.wav  -category memes

Declare it in config/config.yaml so --test-audio can see it, and wire it to whichever keys you want:

sounds:
  memes:
    directory: ../assets/sounds/memes
    mode: random
    avoid_repeat: true

keys:
  F1:
    directory: ../assets/sounds/memes
  F2:
    directory: ../assets/sounds/memes

4. Check it, then reload

./bin/keyshot --test-audio memes     # hear exactly what you added
./bin/keyshot --test-audio all       # hear everything

Then reload KeyShot so it picks up the new files. Assets load once at startup, so a restart is required:

systemctl --user restart keyshot     # if running as a service

or Ctrl-C and re-run it if you started it by hand.

Replacing the gun sounds entirely

Delete the pools you do not want and add your own:

rm assets/sounds/char/*.wav
go run ./cmd/prepare-audio -add ~/sounds/typewriter.wav -category char
./bin/keyshot --test-audio char

To get back to the original gun sounds at any time:

./scripts/prepare-audio.sh           # rebuilds everything from assets/source/

Note that this regenerates assets/sounds/ from scratch, so anything you added with -add is removed. Keep your own originals somewhere outside assets/sounds/ if you want to re-add them later.

Tips

  • Short is better for char. Anything over ~500 ms overlaps heavily while typing. The tool warns you if a char sound is over a second.
  • Long sounds suit rare keys. Escape, Delete and the function keys are pressed seldom, so a 2-second clip works well there.
  • Several sounds per category stop it sounding repetitive; avoid_repeat guarantees the same clip never plays twice in a row.
  • Quiet source files are limited to +12 dB of boost, so very quiet recordings stay quiet. Raise the level in an editor first if needed.
  • Unsupported file? Only MP3 and uncompressed PCM WAV are read. Convert other formats (M4A, OGG, FLAC) to one of those first.

Security

KeyShot needs read access to Linux input devices, which is a meaningful permission. Here is exactly what that means and what the project does to keep it contained.

Why the access is needed

KeyShot plays a sound for a key pressed in any application, so it cannot rely on a focused window. On Linux the only way to observe key events system-wide is to read the kernel's evdev devices at /dev/input/event*. Those nodes are owned by root:input with crw-rw----, so membership of the input group is what grants access.

Anything that can read these devices can read everything you type, including passwords. That is true of KeyShot and of every other global hotkey daemon, macro tool or key-remapper. Grant the permission deliberately.

What KeyShot does with it

  • Opens the devices read-only. It never calls EVIOCGRAB, so it cannot swallow, delay, modify or inject keystrokes. Your typing reaches applications exactly as it would if KeyShot were not running.
  • Uses only the key code. A key code selects a sound from a pool. Key events are never assembled into text.
  • Never stores or transmits anything. Nothing is written to disk, no history is kept, and the binary makes no network connections. The only outputs are audio and the startup log.
  • Ignores everything that is not a key. Mice, touchpads and tablets are not opened at all.

Runs as you, never as root

KeyShot is a systemd user service and runs as your own account. It is not installed system-wide, needs no setuid bit, and scripts/install-service.sh refuses to run as root.

The one privileged action in the whole setup is sudo usermod -aG input $USER, which you run once yourself. KeyShot never asks for or holds elevated privileges at runtime, and the unit adds none: no User=, no Group=, no AmbientCapabilities, no SupplementaryGroups.

Sandbox

The unit restricts the service well below what your user account can normally do:

Setting Effect
NoNewPrivileges=true Cannot gain privileges, ever
ProtectSystem=strict The whole filesystem is read-only to it
ProtectHome=read-only Your home directory cannot be written
PrivateTmp=true Private /tmp, isolated from other processes
ProtectKernelTunables=true Cannot modify kernel settings
ProtectControlGroups=true Cannot modify cgroups
RestrictSUIDSGID=true Cannot create setuid/setgid files
RestrictRealtime=true Cannot obtain realtime scheduling
MemoryDenyWriteExecute=true No writable-executable memory
SystemCallFilter=@system-service Syscalls limited to the ordinary service set

ProtectKernelModules is deliberately absent: it requires dropping a capability that a --user manager cannot drop, and makes the service fail with status=218/CAPABILITIES.

Limiting what it listens to

Auto-detection accepts a device only when it has the kbd handler, advertises auto-repeat, and reports the ordinary typing keys. That last check exists because devices like a Bluetooth headset's AVRCP node otherwise qualify — it carries the kbd handler and even claims KEY_ENTER, but has no letter keys.

To be explicit rather than automatic, pin the device yourself — see Choosing which keyboard to listen to.

Removing the access

systemctl --user disable --now keyshot
sudo gpasswd -d $USER input

Log out and back in for the group removal to take effect.


Architecture

Keyboard input and audio playback are separate packages and never block each other.

startup   read config -> decode every asset into RAM -> open the audio device -> listen
runtime   key event -> category lookup -> in-memory pool pick -> hand to mixer -> return
cmd/keyshot          the daemon
cmd/prepare-audio    source audio -> runtime assets
cmd/inspect-audio    reports duration, rate, level and transients

internal/audio       WAV/MP3 codecs, DSP, sound pools, mixer, PulseAudio engine
internal/config      YAML loading and validation
internal/input       evdev device discovery and the read loop
internal/mapping     key code -> category -> sound resolution
internal/daemon      wiring and lifecycle

The keypress path performs no file access, no decoding, no directory scanning and no allocation:

BenchmarkKeypressPath-4    145.1 ns/op    0 B/op    0 allocs/op

Playback is polyphonic — a new keypress never cancels a sound already playing. One output stream stays open for the life of the process, fed by a software mixer, so no stream is created per key. The mixer holds a fixed number of voices (audio.max_voices, default 32); when all are busy the oldest is replaced, bounding resource use predictably.

A missing or broken sound is reported at startup and leaves that category silent. It never stops the daemon, and audio failure never affects typing.


Troubleshooting

PERMISSION DENIED from --list-devices You are not in the input group in this session. Run sudo usermod -aG input $USER, then log out and back in. Confirm with id -nG | tr ' ' '\n' | grep input.

The service fails with permission denied even though id -nG shows input Your shell has the group but the systemd --user manager does not — it was started before the group was added and keeps its original credentials. Check what the manager actually has:

grep '^Groups:' /proc/$(pgrep -u $USER -f 'systemd --user' | head -1)/status
getent group input      # confirms the membership exists on disk

If the manager's group list is missing the input gid, log out fully and back in. newgrp, sg and systemctl --user daemon-reexec will not fix this.

The service keeps restarting It no longer loops for ever — after 3 failures in 60 seconds systemd stops trying and marks the unit failed. Read the cause with systemctl --user status keyshot, fix it, then systemctl --user reset-failed keyshot && systemctl --user start keyshot.

No keyboards found, or an unexpected device is listed KeyShot selects devices from /proc/bus/input/devices that have the kbd handler and advertise both key events and auto-repeat. Some non-keyboards qualify legitimately — a connected Bluetooth headset exposes an AVRCP node, so its media buttons will also fire sounds. Pin the devices you want if auto-detection picks the wrong set, or misses yours:

keyboard:
  devices: ["/dev/input/event4"]

Run --list-devices to see the current candidates and their paths.

No sound, but no errors Run ./bin/keyshot --test-audio char. It exits non-zero if the output stream failed. Check the sink is not muted (pactl list sinks short).

Crackling or dropouts Raise audio.latency_ms (try 50).

Holding a key machine-guns keyboard.ignore_repeat has been set to false.

Service fails with status=218/CAPABILITIES A sandboxing option in the unit needs privileges a user service does not have. ProtectKernelModules is the usual cause and is deliberately omitted from the shipped unit.

Sounds are cut off All voices are in use. Raise audio.max_voices.

A sound I added is not playing Assets load once at startup, so restart KeyShot (systemctl --user restart keyshot, or Ctrl-C and re-run). Confirm it converted with ./bin/keyshot --test-audio <category>. If you added it to a brand-new category, it also needs a sounds: entry or a keys: override in the config — a folder on disk that nothing references is never loaded.

unsupported container when adding a sound Only MP3 and uncompressed PCM WAV are read. Convert M4A, OGG or FLAC first.

KeyShot warns about a sample rate on startup A WAV was copied into assets/sounds/ by hand rather than added with prepare-audio -add, so it is not 48 kHz stereo. Re-add it with the tool.


Development

Task runner

Common workflows are wrapped in a Taskfile. Install the runner once, from the repository root:

./scripts/install-task.sh

It installs go-task into ~/.local/bin (no sudo, nothing system-wide), tells you if that directory is not on your PATH, and does nothing if the runner is already present. Open a new terminal afterwards if task is still not found.

Do not apt install task. On Debian and Ubuntu that installs Taskwarrior, an unrelated to-do list manager that happens to own the same command name. If your shell says Command 'task' not found and offers to install it with apt, ignore the suggestion and run the script above.

Then, from the repository root:

task              # list every available task
task build        # compile to bin/keyshot
task check        # everything CI runs: fmt-check, vet, test-race

Check you have the right program — go-task reports a bare version number:

$ task --version
3.53.1

If it prints Taskwarrior's usage text instead, another task is earlier in your PATH; either remove it or call this one by its full path (~/.local/bin/task).

Everything the runner does can also be run directly with go, so it is a convenience, not a requirement — see Without the task runner.

Task Purpose
task build Compile the daemon to bin/keyshot
task test Run all unit tests
task test-race Run tests under the race detector (needs a C compiler)
task test-verbose Tests with per-test output
task vet go vet static analysis
task fmt Format all Go code in place
task fmt-check Fail if anything is unformatted (does not modify files)
task check fmt-check + vet + test-race
task cover Coverage per package
task bench Benchmark the keypress path
task clean Remove the binary and cached test results
task install-deps go mod tidy
task run Build and run in the foreground
task devices List detected keyboards
task test-audio -- char Play a category (defaults to all)
task assets Rebuild all runtime sounds — destructive, see below
task assets-inspect Report the character of the source audio
task add-sound -- -add ~/bruh.mp3 -category char Add one of your own sounds
task service-install Install and enable the user service
task service-status / service-logs Status and live logs
task service-restart Restart after a config or sound change
task service-uninstall Stop it and remove it from login startup

./scripts/install-task.sh installs the runner itself and is the only step that is not a task (you need the runner before you can run tasks).

task assets replaces everything in assets/sounds/, so a sound added with task add-sound is lost unless its original also lives in assets/source/. It asks for confirmation before running; task assets-inspect and task add-sound are non-destructive. Copy your own source files into assets/source/ if you want them to survive a rebuild.

Without the task runner

go build -o bin/keyshot ./cmd/keyshot
go test ./...
go vet ./...
gofmt -w .
go test -race ./...

Verification status

Verified on this machine (Ubuntu, kernel 6.8, Go 1.26.6, i5-7300U):

Check Status
go build ./... passes
go vet ./... clean
gofmt -l . clean
go test ./... 74 tests pass
--list-devices detects the keyboard, reports readability correctly
--test-audio (all 6 categories) audio confirmed playing
Asset pipeline rebuild 9 sources to 24 runtime assets, all validated
prepare-audio -add verified: new category, name sanitising, no-overwrite numbering, char auto-shortening, per-key and whole-category config wiring
systemd unit syntax systemd-analyze verify passes
systemd sandbox binary starts and plays audio under the unit's full hardening
Service enabled and auto-starting is-enabled: enabled, is-active: active, starts on login
Live keypress to sound working after the input group and a full re-login
Device filtering Bluetooth AVRCP media node correctly excluded; only the real keyboard is opened
Stable device paths by-path symlink pinned in config resolves and runs; a bad pin fails loudly at startup
go test -race ./... passes

Everything above is verified on this machine. TestMixerConcurrentPlayAndRead drives Play from 8 goroutines against a live Read and is clean under the race detector.

About

keyboard-gun is a lightweight Linux daemon that listens to global keyboard events and plays low-latency sound effects for different key actions. Regular keys produce mechanical sounds, while keys such as Space, Enter, and Backspace can trigger configurable sound effects.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages