Skip to content
WM KeyboardWM Keyboard
Accessibility

Sound packs

The .wmsoundpack format — many recordings of one keyboard, one played per key press and one when you lift, with optional per-key-role sounds.

A sound pack is a key-press sound with more than one recording in it.

A plain key sound addon (type: "sound") is one audio file, and for a synthesized blip that’s exactly right. It can’t express what a recording of a real keyboard sounds like, though. A real board never makes the same noise twice, a Space bar with stabilizers under it doesn’t sound like a letter key, and a mechanical switch makes a second, quieter noise when your finger comes off it. A pack carries all three.

If you just want to install one, see Sound & vibration. This page is for publishing them.

A pack is a ZIP archive named *.wmsoundpack:

cherrymx-blue-abs.wmsoundpack
├── pack.json
└── sounds/
├── 1.wav
├── 2.wav
└── …

Two rules, both enforced on install:

  • pack.json sits at the archive root.
  • Every audio file lives under sounds/.

A path that’s absolute, contains .., or uses a backslash is rejected rather than sanitized. More precisely: the importer never treats an archive entry name as a path at all. It reads each entry into memory under that name as a map key, then writes the bytes to a file it names itself (s000.snd, s001.snd, …), rewriting pack.json to match. So neither a ../ entry name nor a ../ inside the manifest has anywhere to go.

{
"format": "wmkeyboard-sound-pack",
"version": 1,
"id": "cherrymx-blue-abs",
"name": "CherryMX Blue ABS",
"author": "Monkeytype contributors",
"packVersion": "1.0.0",
"description": "Cherry MX Blue clickies under ABS keycaps.",
"gain": 1.0,
"press": ["sounds/1.wav", "sounds/2.wav", "sounds/3.wav"],
"release": ["sounds/1-up.wav", "sounds/2-up.wav", "sounds/3-up.wav"],
"roles": {
"space": { "press": ["sounds/space-1.wav"] },
"enter": { "press": ["sounds/enter-1.wav"] },
"delete": { "press": ["sounds/delete-1.wav"] },
"modifier": { "press": ["sounds/mod-1.wav"], "gain": 0.8 }
}
}
FieldRequiredWhat it does
formatMust be exactly wmkeyboard-sound-pack. Anything else is refused.
versionFormat version, currently 1. A higher number is refused rather than guessed at.
id[A-Za-z0-9._-]+, at most 64 characters. Frozen once published — it’s how installs are keyed.
nameWhat the user sees in the picker.
pressAt least one path. The default key-down recordings.
authorShown on the row.
packVersionSemver, informational. The addon entry’s version is what drives updates.
description
gain0.01.0, default 1.0. See Gain is a cut.
releaseKey-up recordings, played when the finger lifts. See Key-down and key-up.
rolesPer-key-role overrides, below.

Unknown top-level fields are ignored, so a later version can add one without breaking today’s app.

A keystroke has two halves, and a mechanical keyboard makes a noise at both: the switch actuating under the finger, and the stem returning when it comes off. press is the first, release is the second, and the keyboard plays each at the moment it happens. Hold a key down and the second sound waits for you.

An empty release means silence on key-up, not a fallback to press. Most sounds worth typing on are one event — a beep, a pop, an interface click — and inventing a second one for them would double every keystroke. So a pack with no release behaves exactly as packs did before the field was played.

Roles and the split are independent. A role can fill press, release, both or neither, and each field falls back to the pack’s top-level list on its own, so a space role that names only press still plays the pack’s default key-up.

The two lists are drawn from independently: press[3] and release[3] are not a pair and are never treated as one, so the lists can be different lengths.

The user can turn key-up sounds off in WM KeyboardKey pressKey press soundKey release sound, which appears only once a pack that has them is selected. A switch that does nothing visible is worse than no switch — it reads as the feature being broken rather than as the pack not having it.

Record the two halves as separate files. If you are cutting them out of recordings that hold both — which is what the monkeytype packs needed — cut at the quietest point between the two events rather than at a fixed delay: the gap varies by tens of milliseconds between recordings of the same switch, and a fixed cut clips the key-up’s attack on the fast ones.

Don’t normalize the halves separately. A key-up is genuinely quieter than its key-down — a tenth to a half the level on real switches — and levelling them to match makes typing sound like two keyboards.

A role is which kind of key was pressed. There are five:

RoleWhich keys
defaultEverything not listed below: letters, digits, punctuation, emoji. Not written under roles; it’s the top-level press and release.
spaceThe Space bar.
enterEnter and the action key.
delete, including its held repeat and the delete-swipe, plus forward delete.
modifierShift, ?123, ABC, the globe, emoji and the rest of the non-typing furniture.

A role that’s absent falls back to the default, per field. A pack that defines only roles.space.press still plays its top-level press everywhere else, and its top-level gain for the spacebar. A pack with no roles at all behaves exactly like a one-sound addon that happens to have variants.

Unknown role names are ignored rather than refused, so a pack written against a future version that adds one still installs and still works.

The grouping is by what a key sounds like under a finger, not by what it does. Emoji, language switch and the layer keys are all modifier because they sit in the same bottom-row furniture as shift, and anyone who recorded that row recorded them together.

Every press and release is a list, and one entry is picked per keystroke.

The pick is uniform random with one rule on top: the same variant never plays twice in a row while the list has more than one entry. Each role tracks its own last-played index, per half, so alternating between the spacebar and letters never makes either repeat, and a run of key-ups doesn’t depend on how many letters were typed between them.

That rule is a deliberate difference from monkeytype, whose own player picks uniformly across the whole list. On a three-variant set that repeats the previous sample about a third of the time, and a repeat is the exact thing variants exist to prevent.

LimitValueWhy
Audio files per pack64Every file is decoded into the SoundPool when the pack is selected. Key-up recordings count toward it.
Variants per list32
Bytes per audio file4 MBSame ceiling a single-sound addon gets.
Unzipped bytes per pack16 MBCounted while extracting, not read from the ZIP header, so a zip bomb stops mid-stream.
Installed packs20

Going over any of these refuses the install with a message naming the limit. It never truncates the pack to fit: a pack quietly missing half its recordings is worse than one that didn’t install.

One thing that isn’t fatal is a press entry naming a file that isn’t in the archive. That variant is dropped and the rest of the pack is kept, because losing one of ten switch recordings isn’t worth refusing the other nine. A pack with nothing left after that is refused.

WAV, OGG and MP3, checked by header rather than by extension.

Ship WAV unless the pack is big enough for that to hurt. These are 20–150 ms recordings, so compression saves a few hundred kilobytes and costs the thing that matters: MP3 carries encoder delay, and the decoder emits a block of silence before the first real sample. On a 30 ms click that’s a tenth of the sound arriving late on every keystroke. WAV decodes to PCM with nothing in front of it.

Mono is enough. SoundPool plays both channels of a stereo file at the same volume anyway, so stereo doubles the size for a difference nobody hears through a phone speaker.

SoundPool.play takes a volume of 0.01.0 and cannot amplify. gain is therefore an attenuation, never a boost, and is clamped into that range on load. A pack that ships quiet samples and asks for "gain": 2.0 gets 1.0 and stays quiet.

Normalize loud at build time and use gain to pull a role back down: a spacebar that recorded hotter than the letters, say. The user’s own key-sound volume multiplies with it, so it’s for balance within a pack, not for setting the pack’s overall level.

In a repository manifest, a pack is type: "sound_pack":

{
"id": "cherrymx-blue-abs",
"type": "sound_pack",
"name": "CherryMX Blue ABS",
"version": "1.0.0",
"author": "Monkeytype contributors",
"description": "Cherry MX Blue clickies under ABS keycaps. Ten recordings.",
"tags": ["switch", "cherry", "clicky"],
"path": "packs/cherrymx-blue-abs.wmsoundpack",
"sha256": "…",
"sizeBytes": 262144,
"previews": ["previews/cherrymx-blue-abs.png"],
"minAppVersion": 4,
"license": "GPL-3.0-or-later",
"licenseFile": "LICENSE"
}

Notes:

  • Set minAppVersion. sound_pack didn’t always exist, and an older app reads an unknown type as unknown and offers no way to install it. The floor turns that into a message that says why.
  • The manifest id and the pack.json id don’t have to match, and the app keys the install on the manifest’s. Making them the same is still sensible.
  • The addon entry’s version drives update detection.

Installing a pack selects it, the same as a theme or a single sound: a pack that installs without the keyboard making its noise reads as an install that did nothing.

The detail page offers a Preview before installing, which extracts up to eight variants of each half and gives every one its own Play row. One button that played a random variant would turn “how much does this vary?” into a guessing game, and that question is the whole reason to pick a pack over a single sound.

wasi-master/wmkeyboard-monkeytype-sounds is a complete repository of 26 packs built from monkeytype’s sound files, with the importer, validator and preview generator that produced them. It’s a reasonable thing to fork.

It’s also a worked example of the key-up cut: monkeytype plays one file per key press, so its switch recordings hold the return in the same file 100–200 ms behind the press, and the importer finds that boundary and cuts there. Which sets get cut is written down by hand, because a detector can’t tell a key coming back up from the second half of a punch — Fist Fight trips it on all eight recordings and is deliberately left whole.

  • No per-key mapping. Mechvibes-style packs address individual key codes. Roles cover the case that’s actually audible on a phone, the spacebar, without asking anyone to record 104 keys for a keyboard that has about 30.
  • Key-up is a sound-pack feature only. The single-file sound addon type and the built-in styles have one sound each, and there’s no second file to play.