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.
- Linux (tested on Ubuntu, kernel 6.8)
- Go 1.21 or newer
- PipeWire or PulseAudio
- Membership of the
inputgroup (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.
git clone https://github.com/Ahmad-code077/keyboard-gun.git
cd keyboard-gun
go build -o bin/keyshot ./cmd/keyshotKeyShot observes raw key events from /dev/input/event*, which is owned by the
input group. Add yourself to it once:
sudo usermod -aG input $USERThen log out of your desktop session completely and log back in.
A full logout/login is required — this is not optional.
usermodonly 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 yoursystemd --usermanager, which is what starts KeyShot.
newgrp inputandsg inputchange only the single shell you run them in, andsystemctl --user daemon-reexeckeeps 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: inputThis 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.
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 categoryThis 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.yamlKeyShot 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.
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.shThe 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.
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 # activeYou 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 nowstop 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 keyshotsystemctl --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 bootA 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 | 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 |
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 # silentResolution 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.
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.
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.shPreparation 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.
You can replace or extend any category with your own audio — memes, voice clips, movie quotes, different weapons. No Go code needs editing.
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.
go run ./cmd/prepare-audio -add ~/Downloads/bruh.mp3 -category charAdded 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.
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 memesThen in config/config.yaml:
keys:
F1:
sound: ../assets/sounds/memes/airhorn-01.wavOr point a key at a whole folder, so it picks a random one each press:
keys:
F1:
directory: ../assets/sounds/memesAn individual key override always beats that key's 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 memesDeclare 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./bin/keyshot --test-audio memes # hear exactly what you added
./bin/keyshot --test-audio all # hear everythingThen 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 serviceor Ctrl-C and re-run it if you started it by hand.
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 charTo 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.
- 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_repeatguarantees 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.
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.
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.
- 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.
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.
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.
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.
systemctl --user disable --now keyshot
sudo gpasswd -d $USER inputLog out and back in for the group removal to take effect.
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.
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 diskIf 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.
Common workflows are wrapped in a Taskfile. Install the runner once, from the repository root:
./scripts/install-task.shIt 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 saysCommand 'task' not foundand 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-raceCheck you have the right program — go-task reports a bare version number:
$ task --version
3.53.1If 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.
go build -o bin/keyshot ./cmd/keyshot
go test ./...
go vet ./...
gofmt -w .
go test -race ./...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.