FreeSlapMac 1.1.0 cannot be silenced through its own interface. The slap sounds are not played
by the app at all — they are 267 MP3s played by the bundled spank process, and neither
side exposes a volume or a mute. So this builds a copy of the app whose entire sound library is
digital silence at the original duration, then decodes every clip back through macOS's own
AudioToolbox to prove it contains zero non-zero bytes. One bash script. No python, no brew, no ffmpeg.
The lid creak and the slap taps feel like the same setting. They are not. The creak is
a helper I wrote reading the lid-angle sensor and playing a sound — it honours the app's
_creakingEnabled preference, so one switch silences it.
The taps are different. Pull the app apart and the audio simply isn't there:
$ defaults read com.devesh.freeslapmac
{
launchAtLogin = 1;
screenFlash = 1;
sensitivity = "0.3427";
triggerMode = woman;
_creakingEnabled = 1;
}
# five preferences. no volume. no soundEnabled. nothing to mute.
$ /Applications/FreeSlapMac.app/Contents/MacOS/spank --help
--custom DIR --min-amplitude F --cooldown MS --speed F --volume-scaling
# the sound engine. no --mute, no --volume, no --no-audio.
The app launches spank with --custom .../Contents/Resources/Sounds/<mode>
and spank plays those MP3s itself. So "turn the tap sound off" has no supported answer in
1.1.0 — not a hidden checkbox, not a default key. There is no such control anywhere in the bundle.
| route | what happens | |
|---|---|---|
| set a volume preference | There is no such key. The app stores five preferences and audio is not among them. | no such pref |
| kill spank | The root wrapper respawns it on its next loop — it keeps LAST_CMD and restarts the process. | respawns |
| pause detection | Pausing stops the app reacting. The audio was never the app's to pause — it is spank's. | wrong layer |
| turn the Mac's volume down | Works, and silences everything else you own at the same time. | global |
| replace the audio | Nothing left to play. Detection and visuals never touch the audio path, so they keep working. | this build |
A silent MP3 does not need an encoder. One MPEG-1 Layer III frame at 44.1 kHz mono, 128 kb/s is
417 bytes and carries 1152 samples. Its four header bytes are FF FB 90 C0; the side
information and main data that follow are all zero, and all-zero spectral data decodes to exact digital
silence. So the build emits that frame as often as needed:
# one frame, 26.122 ms
printf '\xff\xfb\x90\xc0' > frame; head -c 413 /dev/zero >> frame
# per clip: match the original duration to the nearest frame, then truncate
dur=$(afinfo "$clip" | awk -F': ' '/estimated duration/{print $2}')
frames=$(awk -v d="$dur" 'BEGIN{printf "%d", d*1000/26.122448979591837 + 0.5}')
head -c $((frames*417)) silence.mp3 > "$clip"
Duration is matched rather than flattened to a fixed length on purpose: the clap, the punch and the
laugh are different lengths, and a silent build that changes clip lengths is a silent build that changes
timing. Everything else is a straight copy — ditto, rename it, and re-sign, because touching
resources breaks the original seal. That is cheap here: the shipped app is
Signature=adhoc already, so the copy is ad-hoc signed the same way and inherits the exact
same trust posture.
$ codesign -dv /Applications/FreeSlapMac.app
Signature=adhoc # the original, before I touched anything
$ bash build.sh
copying /Applications/FreeSlapMac.app
-> /Applications/FreeSlapMac Silent.app
silenced 267/267 MP3s, re-signed ad-hoc
My first version used ffmpeg and a thread pool, and every worker wrote to the same temporary
file. Twenty-three of the 267 clips came out corrupted — and the verifier said so instead of assuming
success. That is the entire argument for writing the check first:
# first build, before the fix files silenced: 267/267 silent (peak ≤ -91 dBFS): 244/267 undecodable: 23 # shared temp file, race # after unique temp paths — and after dropping ffmpeg entirely
Then I threw the dependency away: the frame above is pure bash, and the check is
afconvert, which decodes through the same AudioToolbox that spank uses to play
the file. Zero non-zero bytes is not "quiet" — it is the absence of sound, and it is the only result the
script accepts:
$ bash verify.sh == files == original: 267 mp3 silent: 267 mp3 == decode check (AudioToolbox) == decoded: 267 digitally silent: 267 audible: 0 duration mismatches: 0 == signature / identity == signature: valid (ad-hoc) bundle id: com.devesh.freeslapmac (settings carry over) display name: FreeSlapMac Silent executable: identical code (901aecc6a53907cd…) == size == original 15M silent 14M PASS — every clip decodes to digital silence, signature valid, same code and settings.
That last line matters twice: identical code means the executable is byte-for-byte the original (only the signature blob differs), and same bundle id means your sensitivity, sound mode, cooldown and lid-creak preference carry straight over instead of resetting.
Slap detection, screen flash, combo counter, slap count, hot corners, settings — none of it goes through the audio path, so all of it behaves exactly as before. The fork is the same binary with a silent library wired to it.
/tmp/freeslapmac_cmd and each launch kills every
spank it can find, so the two instances fight. switch.sh silent|loud quits one
politely before opening the other.FreeSlapMac.app you have and silences whatever MP3s are in it, so an upstream update works
fine — but an update also installs a fresh loud app, so re-run the build afterwards.To silence the sound I had to know who plays it, so I read the wrapper the app writes at launch. It runs as root and it does this:
FIFO="/tmp/freeslapmac_cmd"
...
chmod 666 "$FIFO" # world-writable
...
start_spank() {
eval "$1" >> "$OUTPUT" 2>&1 & # as root
}
...
if read -t 1 CMD < "$FIFO"; then
start_spank "$CMD"
Any local process — or any other user on the machine — can write a command string into that FIFO and have it executed as root while the app is running. That is not something this build needs, and using it would have been the fastest way to build the mute: one line to the FIFO would restart the sound engine against a silent folder. I didn't, for the obvious reason — a mute that depends on a local root-execution channel is a worse problem than the sound. It is worth reporting upstream, and worth not leaving either build running on a shared or guest account in the meantime.
First launch asks for your admin password once, exactly like the original — spank needs it
to read the accelerometer. Settings are shared with the loud build, so if you keep both, remember they
are the same preferences: change your sensitivity in one and the other follows.
| file | what it does |
|---|---|
| build.sh | Copies the app, silences all 267 MP3s at matched duration, renames it, re-signs ad-hoc, strips quarantine, then runs the verifier. Positional args for source and destination; FORCE=1 to replace an existing build. |
| verify.sh | Decodes every clip through AudioToolbox and fails unless each one is 100 % zero PCM and within 60 ms of the original duration. Also checks the signature, the bundle id and that the executable is unchanged. |
| switch.sh | silent / loud — quits one build politely (so its root wrapper tears down) and opens the other. |
| README.md | The same argument as this page, with the commands, the expected verifier output and the limitations. |