Somewhere under your keycaps lives Klicky. It is curious, a little mischievous, and very pleased that keyboards make sounds. One click catches its attention. A steady run of typing gives it a rhythm to follow. When you take a break, it curls up and enjoys the quiet too.
The software behind that little creature is a Rust utility that gives ordinary typing a mechanical-keyboard voice. Ten sound packs, a volume control, and a small presence in your desktop. Most of the time, that is all you need to think about.
Building it sent me considerably further down the stack.
I just wanted it on my own machine
I first saw somebody using Sauhard Gupta’s original Klicky project on their Mac. I liked it and wanted to use it myself. There was one small problem: I was running Omarchy on Arch Linux.
So I did what an engineer with some time on their hands does. I took the repo and started moulding it to work on my setup.
First Omarchy. Then Linux more broadly. Then Windows. I kept adding to it, and it kept growing.
The original macOS project already had good foundations: a Rust daemon, decoded sound caching, local socket commands and KeyEcho-compatible packs. Those are Sauhard’s contributions. My work grew around Linux input, audio buffering, normalization, desktop integration and packaging, followed by the Windows implementation and further macOS lifecycle work.
I had found something I enjoyed and wanted it to fit the machine I actually used.
A voice ready before you press a key
The obvious version of a keyboard-sound app is easy to describe: receive a keypress, find a sound, play it.
The important detail is when each piece of work happens.
Opening a file, decoding OGG audio and creating an output stream are things I don’t want between a keypress and its click. Klicky prepares the sounds when a pack loads. Each pack has one sound.ogg recording and a config.json map that identifies the slices for individual keys.
Loading decodes the recording into PCM samples, prepares the slices, normalizes quiet ones and keeps them in shared buffers. A keypress then selects prepared audio for the already-running mixer. Several key sounds can overlap, so a new click does not have to wait for the previous one to finish.
A small part of that preparation solved another very audible problem: some packs were much quieter than others. The normalization uses an 85% peak target and caps amplification at 6×. Here is the gain decision from the sound loader:
let gain = (TARGET_PEAK / peak).clamp(1.0, MAX_NORMALIZATION_GAIN); if gain == 1.0 { return; } The function handles silent slices before this step. The lower bound leaves already-loud samples alone; the upper bound prevents an extremely quiet recording from getting an enormous boost. The useful architectural choice is that this work happens during preparation, rather than on every keypress.
The code was fast. The sound still felt late.
An early Linux run recorded an average callback-to-mixer dispatch of 0.241 ms across 55 keypresses, with a p95 of 0.56 ms. That was a useful clue: the input handler was already handing sounds over quickly.
Further along the path, the audio device had negotiated a buffer of 1,102 frames at 44.1 kHz. That works out to roughly 25 ms. A fast hand-off was feeding a much larger buffer.
After the move to Rodio 0.22 and a smaller fixed-buffer request, the tested PipeWire route negotiated 256 frames, about 5.8 ms. The audible lag was gone or clearly reduced in that hardware check, and the stream stayed clean during normal typing.
These bars show calculated buffer durations. The dispatch figures describe the separate input-to-mixer step. Recorded environment and results.
The Unix output setup tries 256 frames, then 512, then 1,024, then a device-selected fallback. Small buffers help responsiveness, but the application still needs to open a working stream on the user’s device.
I carried the same debugging habit into Windows: inspect the actual output route, then sit down and type. Laptop speakers felt near-immediate and comfortable. AirPods had a noticeable delay. That was a useful reminder to include the device in the investigation instead of endlessly optimizing the quickest part of the program.
Three desktops, three native edges
The part that translates a physical keyboard into events depends on the operating system.
| Platform | Input | Desktop integration |
|---|---|---|
| Linux | evdev, below Wayland and X11 | systemd user service; Omarchy controls |
| macOS | Native event taps | launchd and a menu bar helper |
| Windows | Passive Raw Input on a dedicated thread | Native tray app and console client |
Linux responds to initial key-down events and ignores releases and held-key repeats. Windows maintains repeat state per device. The adapters turn those platform events into the key names used by the packs.
The sound data can be shared without making every operating system use an identical lifecycle. Linux and macOS keep their persistent Rodio output. Windows retains the Rodio mixer but uses a Windows-specific CPAL renderer and audio supervisor, including recovery when the output device changes.
That separation let the Windows work grow without replacing an audio path that already worked on Linux and macOS. The shared part earns its place through actual common behaviour: pack definitions, decoding, slices and prepared audio.
A tiny tray menu can still get in the way
A background utility needs more than a quick key handler. It also needs to stay pleasant while someone changes a sound pack, adjusts the volume or asks it to quit.
On Windows, pack preparation and persistence run away from the input and audio callbacks. The tray submits controls asynchronously. A pack change is prepared and saved before it becomes active, rather than making the typing path wait for a file decode.
The queues and active playback are bounded. During audio recovery, obsolete events are discarded so reconnecting a device does not play a burst of old typing. A second launch is supposed to find the existing owner instead of starting another engine with another tray icon.
Some of the most useful feedback was much less abstract: “the volume didn’t change,” “there are two tray icons,” and “some keys went quiet.” Those reports sent the investigation through process ownership, control state and aggregate input/audio diagnostics. The diagnostics can distinguish dropped or stale events without storing typed text.
The intermittent missing-sound report remains open while I collect recurrence evidence. A later typing check did not reproduce it; the aggregate diagnostics are there to help catch the next occurrence.
Leave room for a little personality
I wanted the project to feel like the thing that made me want it in the first place: a small, enjoyable addition to an ordinary day at the keyboard.
Klicky gives that feeling somewhere to live. The sound packs are its different voices. The illustrations suggest a little world under the keys. It is pleased when the typing gets going, and perfectly content when it stops.
The actual controls remain familiar: choose a pack, set the volume, pause, resume, quit. The character can make the project welcoming while the settings still say exactly what they do.
Where it is now
The current downloads are Linux v0.4.0 packages, a macOS source installation and a separate Windows 11 x64 development preview. The native Windows app and tray controls are implemented; final package/recovery and broader release qualification remain part of the work toward the next feature release.
You can meet Klicky and listen to its ten sound packs, explore the Rust source, browse the available releases, or read the architecture guide. There is also a longer Klicky case study on my portfolio, alongside my other projects.
What began as “I want those clicks on my laptop” has involved input APIs, audio scheduling, concurrency, process ownership and packaging. The interaction on the other end is still wonderfully small: press a key, and Klicky answers.
If you’ve built a tool where a tiny delay changed how it felt, I’d be interested to hear where the time turned out to be hiding.
Related reading: Native edges: principles for native apps with a shared core, by Pedro Piñera Buendía, is a useful companion discussion of shared logic and native platform boundaries.
Drafted with AI assistance from my development work and project notes.




![Laravel 13 Puts #[Table] on Your Models. PHP Won't Load That Class Until newInstance()](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcz4ytbajureytql7wwi4.jpg)