System-wide keyboard sound effects for macOS, written in Swift with SwiftUI and AppKit.
It listens for physical key presses with a read-only CGEventTap and plays a
pre-decoded effect through a persistent AVAudioEngine, in every application, without
intercepting, altering or delaying the keystroke itself.
No web view, no Electron, no on-screen keyboard: a menu-bar accessory with a native SwiftUI window. Nothing is rendered in a browser and no keystroke is simulated.
- Deployment target: macOS 14.0 (
LSMinimumSystemVersion) - Built and tested here on macOS 27.0.1 (26A434), Apple Silicon, Xcode 27.1 / Swift 6.4
- Bundled audio: 127 original synthesised effects across 6 profiles (3.3 MB)
The package manifest is the project: open Package.swift in Xcode and the KeySound
executable scheme appears, or use the command line.
swift build -c release # compile every target
swift test # 87 unit tests (66 SoundCore + 21 KeySound)
Scripts/build_app.sh # -> dist/KeySound.app (arm64 + x86_64, signed with your keychain identity)
Scripts/build_app.sh --run # ...and launch it
open dist/KeySound.app # start it later
Scripts/make_dmg.sh # -> dist/KeySound.dmg for other MacsScripts/build_app.sh is required for real use. SwiftPM cannot emit an app bundle, and
a bare executable has no bundle identity, which is exactly what Input Monitoring and the
login item register against. swift run KeySound does work for development — the app
detects that layout, logs it, and loads the same assets — but macOS treats that binary as
a throwaway process for permissions, so grant nothing to it.
Other scripts:
Scripts/check.sh # build, printing only errors/warnings
Scripts/make_icon.sh # Assets/keysound-app-icon.png -> Assets/AppIcon.icns
swift run SoundForge # regenerate every bundled WAV from SoundRecipes
swift run SoundForge --analyze # per-effect length / peak / RMS / brightness
swift run KeyProbe --tap 20 # is a keyboard tap actually delivering events here?open dist/KeySound.app— a keyboard icon appears in the menu bar. Click it for the quick panel, or choose Open KeySound for the full window.- Open the Permissions tab and press Request access…. macOS adds KeySound to System Settings ▸ Privacy & Security ▸ Input Monitoring but leaves it off; flip that switch on. (There is no way for an app to tick that box itself.)
- That switch is the one that matters and it defaults to off — nothing plays until
you tick it. KeySound polls the grant and re-creates the listener the moment the switch
reads on, because a tap built before the grant is permanently deaf, so typing usually
works without a relaunch. If the panel still says
Denied, press Restart listener (rebuilds the tap without quitting) and, failing that, quit KeySound completely and open it again (Quit KeySound in the panel, thenopen dist/KeySound.app). Do not assume the app is broken: in a live test on this Mac, five listener restarts stayed deaf simply because the switch had not actually been turned on. The Diagnostics log says which case you are in, because every listener start logsInput Monitoring: <state>on the same line. - Type. The dashboard's Last category heard and the menu-bar panel's "Heard N keystroke(s)" start counting.
Only Input Monitoring is required. Accessibility is listed because the Settings pane shows both for keyboard listeners; KeySound uses a listen-only tap and never asks for the right to change input.
To keep it after a rebuild, put the bundle somewhere stable first
(mv dist/KeySound.app /Applications/) and grant once. Because build_app.sh signs with a
named identity, the grant is tied to the certificate rather than the binary, so rebuilding
in place no longer costs a re-grant — the one exception is a build that has to fall back to
ad-hoc signing, which identifies the app by its binary hash.
Menu bar (no window needed): enable/disable keyboard sounds, mute output, master
volume, switch profile, test sound, restart the listener, open the window. The icon
reports state — plain keyboard while events flow, · while armed but silent, × when
off, speaker-slash when muted.
| Shortcut | Action | Available when |
|---|---|---|
⌘T |
Test sound | key window or open panel |
⌘R |
Restart listener | key window |
⇧⌘E |
Enable/disable keyboard sounds | always (main menu) |
⇧⌘M |
Mute | always (main menu) |
⇧⌘O |
Open KeySound | always (main menu) |
Tabs: Dashboard (listener state, measured latency, last category heard, quick controls) · Profiles (six bundled sets, each with previews per key group) · Customise (per-category level and your own audio; one key can override its category) · Permissions (live status, setup guide, limitations, troubleshooting) · Settings (behaviour, latency, privacy, diagnostics, about).
Preferences persist under com.qoder.keysound; Reset preferences restores defaults and
restarts the listener.
| Profile | Directory | Files | Character |
|---|---|---|---|
| Classic Typewriter | classic_typewriter |
20 | carriage clicks, bright and dry |
| Mechanical Keyboard | mechanical_keyboard |
22 | switch clack with a case thump |
| Gunshot | gunshot |
33 | loud percussive cracks — ships with a volume warning |
| Retro Computer | retro_computer |
18 | beige-machine beeps and relay clunks |
| Sci-Fi | scifi |
19 | filtered zaps with short tails |
| Soft & Minimal | soft_minimal |
15 | quiet felt taps |
| Custom | — | — | your own files; falls back to Mechanical until each group is filled |
Every file is generated by this repository (Sources/SoundCore/SoundRecipes.swift +
Tools/SoundForge), so there is nothing to license, attribute or download. SoundForge
re-encodes each WAV, decodes it back, and fails the run if any effect is silent, clipped
or unreadable. The recipes are deterministic: the noise seeds come from an FNV-1a hash of
the profile/category/variant strings, so swift run SoundForge reproduces the bundle
byte-for-byte.
Customise ▸ Choose file… copies the audio into
~/Library/Application Support/KeySound/Custom/ and trims it to the first 3 seconds, so
moving or deleting the original cannot break the mapping. AIFF, WAV, M4A and MP3 all work
(whatever AVAudioFile can decode). Imports are re-decoded into the output device's
format on the next engine start, and the old copy is deleted when you remove the override
or reset preferences.
One AVAudioEngine, built once at launch and left running. 24 voices, each a
AVAudioPlayerNode → AVAudioMixerNode pair into the main mixer, so overlapping keystrokes
are real polyphony. Every effect is decoded once into an AVAudioPCMBuffer; a keystroke
allocates nothing — it picks a voice and schedules the buffer with .interrupts.
The keystroke path is: listen-only tap on a QOS_CLASS_USER_INTERACTIVE thread →
KeystrokePipeline.route (a lock-free read of an immutable settings snapshot) → one hop
onto the audio queue → schedule. The Latency card in Settings shows what this costs,
measured at scheduling time (Task → queue → engine.scheduleBuffer), separately from the
output device's own buffer delay, which is reported as deviceBufferMS. "Prefer small
device buffer" asks the CoreAudio device for 128-frame blocks; it is off by default
because that request is global to the system and other apps can react badly to it.
Voice stealing is deliberate: when all 24 voices are busy, a prominent category (space, enter, escape) takes over the voice that has been playing longest, and the older completion callback is rejected by token so the pool cannot drift.
Read-only, and read narrowly. From each keystroke KeySound keeps only the virtual keycode, the auto-repeat flag and the source process id (used to discard its own events) — enough to pick a sound and nothing more.
- No text, characters, typed content, passwords, focus, window titles or clipboard.
- No network code, no analytics, no crash reporting, no telemetry.
- The session log in Settings ▸ Diagnostics is in-memory only, capped at 120 lifecycle entries, and leaves the process only when you press Copy diagnostics.
- Typed-key classification consults the current input source layout so
Asounds likeAon any keyboard; that mapping is used to name the key group, never recorded. - Secure Input is enforced by macOS, not by KeySound: while any app holds it, the window server gives keyboard events to no user-space listener. KeySound reports this state rather than guessing.
-
Secure Input. Password fields, Touch ID prompts, Terminal's Secure Keyboard Entry and
loginwindowall take it. No app — including KeySound — hears keystrokes while it is held. Find out who holds it:ioreg -l -w 0 | grep -o 'kCGSSessionSecureInputPID"=[0-9]*'
(KeySound's Permissions tab reports the same thing live, with the offending pid.)
-
The login window, FileVault pre-boot, Recovery, the lock screen and any other user's session deliver nothing. Screen lock alone is enough to silence the tap.
-
A few protected system dialogs deliberately isolate themselves from event taps.
-
Elsewhere it is genuinely system-wide: Safari, Chrome, VS Code, Terminal, Notes, Messages, and full-screen Spaces, because a session tap does not care which app is frontmost.
-
A tap created before the permission grant stays deaf; the app rebuilds it when it sees the switch turn on, but a grant that arrives while KeySound is not running is only picked up at launch.
-
defaultsare per-user; KeySound does nothing for another account. -
The bundle is universal (arm64 + x86_64). The Intel slice was verified to load and run under Rosetta on this Mac — engine started, tap created, 15 sounds preloaded — but not on physical Intel hardware, so treat real-Intel reports as still unconfirmed.
These need your keyboard and ears; the automated suite cannot cover them.
- Rapid typing. Open Notes and type at full speed for 30 seconds.
Expect: one click per key, no crackle, no dropped characters in the document. The
Latency card's measured figure stays in single-digit milliseconds;
droppedstays 0. - Held key. Press and hold
j. Expect: by default one sound, not a stream — key repeats are ignored until you turn Play key repeats on in Settings. - Every group. Press Space, Return, Delete, Escape, Tab, each arrow, a modifier,
F1, a number row digit and a keypad digit. Expect: each sounds distinct in the active profile; the dashboard names the category it just heard. - Profile switch. Change profile in the menu-bar panel while typing. Expect: the new texture arrives within one keystroke (the set is preloaded on switch).
- Mute and disable. Toggle Mute output and Keyboard sounds.
Expect: mute silences previews and keystrokes alike; disabling stops the tap (the
menu-bar icon shows
×) and previews still work. - Other apps. Repeat 1 and 3 in Chrome, Terminal and a full-screen app on a second Space. Expect: identical behaviour, no change to what those apps receive.
- Device swap. Plug in or switch to another output device mid-typing. Expect: sound follows the device within about a second, with one log line in Diagnostics and no restart needed.
- Permission revoked. Remove KeySound in System Settings ▸ Input Monitoring, then
re-add it. Expect: the icon goes to
·and the dashboard says the listener is armed but receiving nothing — never "working". Press Restart listener after re-granting. - Secure input. Turn on Secure Keyboard Entry in Terminal. Expect: KeySound says Secure Input is active and goes quiet; turning it off restores sound without relaunching.
- Window closed, machine idle. Close the window, leave it an hour, type.
Expect: still working. Quit via the panel's Quit KeySound; the tap and engine
stop cleanly (no orphaned
KeySoundinps). - Login item. Enable Launch at login in Settings, log out and back in.
Expect: the menu-bar icon returns. Requires the bundle in a stable location — a bare
swift runexecutable cannot register itself.
swift test # 87 tests, 12 suites
swift test --filter PCMLoaderTests # just the decode path
swift test --filter AudioOutputTests # does audio actually reach the output bus
swift test --filter LayoutClassifierTests # is the keystroke thread Carbon-freeTests/SoundCoreTests covers the pure layer: the keycode table (63 named keys, every
category reachable), layout classification including non-Latin characters, the WAV encoder
header byte-for-byte, biquad and tone maths, seeded noise, the voice pool's
steal/token/reuse rules, the variant rotator's no-consecutive-repeat guarantee, the profile
catalog, and every one of the 127 renders (audible, finite, short, reproducible, and
different from the other profiles as a texture rather than a gain).
Tests/KeySoundTests covers the app's decision-making: preference snapshot precedence
(key override ▸ category override ▸ bundled), profile-scoped overrides, the custom
profile's fallback, volume stacking with the full-scale clamp, mute semantics, Atomic
under 200 concurrent writers, and PCMLoader against 44.1 kHz and 48 kHz stereo output.
AudioOutputTests is the one that separates "no sound" from "no keystrokes": it starts a
real AVAudioEngine with the app's own player → mixer → mainMixer graph, taps the main
mixer, schedules a bundled asset decoded for the current output device and asserts the
render thread delivered non-silent samples. Verified passing on this Mac (44.1 kHz
built-in output), including the two-voice case that proves overlap is real polyphony.
Three of those tests were written after real failures found by launching the packaged app:
assetNamereturned<profile>/<file>whileSoundsRootalso prefixed the profile directory, so the bank looked for a path with the folder twice and every key was silent.everyAssetNameResolvesToAFileInTheBundlenow checks the shipped tree itself.PCMLoader.convertdemanded.haveDatafromAVAudioConverter. When the output device differs from the assets' rate, a finite source legitimately ends on.inputRequired, so the guard rejected everything; and one call emits at most 4096 frames, which truncated the longer effects.convertnow drains the source in chunks — verified by asserting the frame count is within 2 % of the theoretical resampled length.- The packaged app died with
EXC_BREAKPOINT (SIGTRAP)the moment a physical key was pressed. Carbon'sTISCopyCurrentKeyboardLayoutInputSourceasserts that it runs on the main queue, andLayoutClassifierused to translate keycodes lazily from whoever asked — so the first keystroke delivered on the tap's own run-loop thread reached Carbon and_dispatch_assert_queue_failraised.LayoutClassifiernow builds the entire keycode → character table in one@MainActor rebuild()and publishes an immutable, internally locked snapshot, so the tap thread cannot reach Carbon at all. AppKit's input-source notification andAppControllerstartup both trigger a rebuild on the main thread, andKeyRouterlatches a single queued rebuild for keystrokes that arrive before the first table exists.readingFromAKeystrokeThreadNeverTouchesCarbonmaps all 128 keycodes from a detached task — the same call that killed the app — androutesLayoutIndependentKeysBeforeAnyLayoutIsReadcovers the pre-rebuild window.
log show --last 5m --predicate 'subsystem == "com.qoder.keysound"' --style compact
ps -o pid,%cpu,rss -p $(pgrep -x KeySound)| Symptom | Cause and fix |
|---|---|
Icon shows ·, dashboard says "armed but silent" |
The grant landed after the tap was created. KeySound re-creates the listener when it sees the switch turn on; if it is still silent, press Restart listener — the switch probably never actually took. |
Icon shows ×, nothing heard at all |
Sounds are switched off, or Input Monitoring is off for this build. A rebuild signed with the same certificate keeps its grant; one that fell back to ad-hoc does not. |
| Test makes no sound but keystrokes do (or neither works) | Mute. KeySound now says so: the Diagnostics log reads KeySound is muted — un-mute to hear the test sound. If instead it reads Sound playing through "<device>", the audio path is fine and the problem is the keyboard grant. |
Log repeats Tap is armed but no events have arrived |
macOS has not granted Input Monitoring to this app. Switch the newest KeySound row on and delete stale duplicates (−); if the list has no row at all, the build was signed ad-hoc and TCC never saw it as the app you granted. |
| App quits by itself, or every keystroke is silent right after launch | Look for a crash report: ls -t ~/Library/Logs/DiagnosticReports | grep KeySound | head (newest first; the .ips is JSON — the threads entry with "triggered": true has the backtrace). The SIGTRAP whose stack ends in LayoutClassifier.refresh was Carbon's main-queue assertion firing on the tap thread and is fixed in this build; a report naming anything else is a new bug. |
| Only quiet in password fields | Secure Input. Expected; see the ioreg command above. |
| Distorted or crackling first keystroke after a profile change | The profile was still decoding; type a few keys or wait for the Diagnostics line "N sound(s) preloaded and ready". |
| "No sound could be loaded — …" notice | The WAV tree is missing from the bundle. swift run SoundForge && Scripts/build_app.sh. |
| No Dock icon and no window | Intended: KeySound is a menu-bar accessory. Settings ▸ Show in Dock adds it back. |
| Login item will not turn on | Run the .app from a stable path (/Applications), not from swift run. |
Idle cost measured on this Mac while running with the listener armed: 0.0 % CPU, 84 MB resident.
Scripts/build_app.sh signs with the first real codesigning identity it finds in the
login keychain (Developer ID Application, Apple Development, or
3rd Party Mac Developer Application) and falls back to ad-hoc only when there is none.
--adhoc forces the fallback; KEYSOUND_SIGN_IDENTITY="…" names a specific identity.
On this Mac it resolves to the Apple Development identity that Xcode already put in the
login keychain, and the bundle comes out as
designated => identifier "com.qoder.keysound" and anchor apple generic
and certificate leaf[subject.CN] = "Apple Development: <you> (<CERTID>)"
and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */
That requirement names the certificate, not the binary, which is the difference that
matters: two consecutive builds produced different executables
(4e952a54… → bb1a4509…) and a byte-identical requirement, so the Input Monitoring
entry — TCC matches on the requirement — now survives every future rebuild. The grant
that was pinned to an ad-hoc cdhash is dead, so expect exactly one more re-grant, after
which Scripts/build_app.sh no longer costs you anything. Scripts/make_dmg.sh inherits
whatever identity the app carries, so image and payload never disagree.
Scripts/make_dmg.sh
# dist/KeySound.dmg — KeySound.app, an Applications shortcut to drag onto,
# "Getting Started.txt" for the recipient, and the app icon as the
# volume icon (the image is HFS+ because APFS volumes ignore that file)Verified on this Mac: hdiutil verify reports a valid checksum, the image itself is signed
by the same identity as its payload (Authority=Apple Development: …, plus its Team ID),
the KeySound.app inside is byte-identical to the one just
built, the Applications link resolves, .VolumeIcon.icns is on the mounted root, no
com.apple.quarantine travels with the app, Contents/Resources/AppIcon.icns has all ten
icon slots, and all 127 WAVs survive the copy. spctl --assess --type exec still reports
rejected, origin=Apple Development: … — a development certificate is not a Developer ID
and nothing here is notarised, so a recipient approves the first open manually. An Apple
Development signature is the right tool for your Mac; hand out Developer ID +
notarization for anyone else's.
To remove that first-launch warning for real, you need an Apple Developer ID and a notarization round trip:
KEYSOUND_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" Scripts/build_app.sh
codesign --verify --deep --strict --verbose=2 dist/KeySound.app
xcrun notarytool submit dist/KeySound.dmg --keychain-profile myprofile --wait
xcrun stapler staple dist/KeySound.dmgNo entitlements are required: a listen-only tap needs no sandbox exemption and the app is
not sandboxed. Hardened runtime signing is untested here, and with --options runtime
Apple Events or future helper processes may need entitlements. Whatever you sign, the
TCC entry belongs to the designated requirement: keep the same signing identity and path
so users grant Input Monitoring once. Ad-hoc signing cannot do that — the requirement is
the binary hash, so every rebuild is a new app to macOS and the grant has to be redone.
That is precisely why build_app.sh prefers a named identity.
The app icon is Assets/AppIcon.icns, generated from Assets/keysound-app-icon.png
by Scripts/make_icon.sh and installed by build_app.sh; CFBundleIconFile points at
it. It is what the Dock, Finder and — usefully — the Input Monitoring list show.
Package.swift Xcode-openable manifest (macOS 14, five targets)
Sources/SoundCore/ pure logic: categories, key mapping, layout classifier,
profiles, DSP + WAV, builder, recipes, voice pool
Sources/KeySound/App/ entry point, main menu, AppController, KeystrokePipeline
Sources/KeySound/Keyboard/ CGEventTap monitor, keycode -> category router
Sources/KeySound/Audio/ AVAudioEngine graph, sound bank, PCM loader, custom store
Sources/KeySound/Permissions/ Input Monitoring / Accessibility / Secure Input polling
Sources/KeySound/Model/ AppSettings (UserDefaults) and SettingsState snapshots
Sources/KeySound/UI/ SwiftUI: dashboard, profiles, customise, permissions,
settings, menu-bar panel; AppKit status item
Sources/KeySound/Login/ SMAppService login item
Sources/KeySound/Diagnostics/ in-memory event log
Sources/KeySound/Resources/Sounds the 127 generated WAV files
Assets/ app icon master art, AppIcon.icns, recipient guide
Tools/SoundForge/ asset generator (+ --analyze, --profile, --debug)
Tools/KeyProbe/ development tap and key-table inspector
Scripts/ build_app.sh, make_icon.sh, make_dmg.sh, check.sh,
Info.plist
- Per-app sound profiles or per-app mute; the tap is deliberately all-or-nothing.
- Recording a keystroke-to-sound mapping from an on-screen keyboard (specifically avoided: it would not be system-wide).
- Importing a whole sound pack at once; overrides are per category or per key.
- iCloud or any sync of preferences; nothing leaves the Mac.
- Notarization and App Store packaging.
build_app.shproduces a universal (arm64 + x86_64) bundle, andmake_dmg.shproduces and verifies adist/KeySound.dmgsigned with your keychain identity; a Developer ID certificate and the notarytool round trip were not run here, so the DMG still trips Gatekeeper on a fresh Mac until the recipient approves it once.
MIT — see LICENSE.
The code and the 127 bundled effects both come out of this repository: Tools/SoundForge
renders every WAV from the recipes in Sources/SoundCore, so there is no third-party audio
carrying a different licence. The icon artwork in Assets/ was supplied for the project and
is not covered by that statement — check its own terms before redistributing it.
Prebuilt disk images are attached to tagged releases rather than committed, because dist/
is git-ignored. To make one yourself: Scripts/build_app.sh && Scripts/make_dmg.sh.