isaac1804.com · build log native app swift · 1,170 lines 10/10 + 8/8 tests arm64 · macOS 13+

FreeSlapMac's lid-creak switch had no sensor code — so I wrote the app that reads one

FreeSlapMac 1.1.0 ships a Lid Creaking Sound toggle: the label, the description, the _creakingEnabled preference in its binary, and a 267-clip sound pack that includes creaking.mp3. What it does not ship is a single line that reads the lid-angle sensor. This is the native app that closes the gap — it reads the sensor 20 times a second, decides whether the lid actually moved, and plays that unused clip. Then it turned out the decision was the hard part: my first sensitivity setting fired on a one-degree movement, and the reason is arithmetic.

app1,170 lines Swift
sensor rate20 Hz, unprivileged
gates per creak3 travel · speed · sustain
boundary tests10/10 + 8 scripted timelines
download469 KB, signed arm64
adminnone no kext, no root
the build
⬇ Lid Creak 2.2.0 (469 KB) Lid Creak.app · LidCreak.swift · build.sh · README · MIT Read every file The Python version this replaces
sha256  e9085d81b6fa84dd365ef174cec46c5f02ff9ac831be4a137a5d340597a48bc0

First launch needs right-click → Open: the bundle is ad-hoc signed, not notarised. It reads the creak sample out of your own /Applications/FreeSlapMac.app — the audio belongs to that app's author, so it is not redistributed here.

01The switch that isn't wired to anything

The evidence is all inside FreeSlapMac's own bundle, and it took reading the binary rather than the UI to see it:

what a working feature would needwhat FreeSlapMac 1.1.0 has
a UI control✔ “Lid Creaking Sound” toggle + description text
a stored preference✔ _creakingEnabled in com.devesh.freeslapmac
a sound asset✔ creaking.mp3, one of 267 clips in the pack
code that reads the lid sensor✘ none — no HID usage page 0x20, no 0x8A
code that references the asset✘ zero references to creaking.mp3

So the toggle writes a preference nothing reads, and the sound sits in the bundle unused. That is what “it doesn't work” meant: not broken, absent. The rest of this page is the app that supplies the missing half, and then the part that took three attempts — deciding when a movement is a creak.

02What the sensor actually hands you

The MacBook lid angle is an HID feature report any user process can read — no root, no kernel extension, no private framework:

deviceIOHIDManager, usage page 0x20 (sensors) / usage 0x8A (lid angle) readfeature report 1 → uint16 little-endian, degrees resolution1° — whole degrees, no fractions sampled20 Hz (50 ms), the app's own loop drops outwhen the lid closes the device disappears; re-enumerated on the next sample

That third row — whole degrees — is the whole bug in the next section. A sensor that reports integers cannot tell you how fast something moved inside one sample; it can only tell you that the number changed by at least 1.

03The arithmetic that made “Low” fire at one degree

The first version measured speed the obvious way: the difference between two consecutive samples, divided by the sample interval.

speed = |angle[n] − angle[n−1]| / 0.05 s

one quantisation step:  1° / 0.05 s = 20 °/s
thresholds shipped in 2.1.0:  Low 15 · Normal 8 · High 5 °/s

Twenty degrees per second is above all three. The least sensitive setting was already more sensitive than the smallest movement the hardware can express, so every setting fired on a 1° twitch — and a hand resting on a laptop produces exactly that, constantly. The user's report was precise: “the sensitivity didnt matter i did low and i angle changed 1 degree still creaked”. It wasn't a slider that needed different numbers. It was the wrong quantity being measured.

why the first build also looked silent A separate failure from the same era: the earlier Python helper logged 422 triggers that nobody could hear, because the Mac's output volume was 0 and muted — the system log showed 33 volume-up and 33 volume-down key events in twelve minutes. An app that plays a sound has to be able to say the output is muted; --selftest now exits 2 and prints exactly that.

04What 2.2.0 measures instead

Three gates, each answering a different question, and none of them trusting a single sample:

gate 1 · travel
≥ 3°
How far did it move across a 0.35 s window? One or two degrees is jitter, a knock, or a hand leaning on the lid. Below 3° nothing else is even evaluated.
gate 2 · speed
0.15 s span
Speed is taken across a span at least three samples wide, so quantisation divides by time instead of defining it: a 1° blip now measures 6.7 °/s, not 20.
gate 3 · sustain
2 ticks
The conditions must hold for two consecutive ticks (~100 ms). A single spike — the classic false positive — cannot pass on its own.

With the measurement fixed, the thresholds could finally mean something, and they were rescaled to the new numbers: Low 35 · Normal 22 · High 14 °/s. Then a 1.4 s retrigger cooldown, so a slow drag creaks once rather than machine-gunning.

Two constants exist purely to survive sensor noise, and are worth naming because they are the kind of thing that looks like a magic number until you know the failure: a span shorter than 0.18 s is not trusted for travel, and a gap larger than 0.5 s between samples (sleep, stall, app suspended) is discarded rather than divided into a fake velocity. A jump of more than 40° in one step is treated as the lid closing, not as speed.

05Proof that runs without touching the laptop

Testing this by hand is hopeless — you cannot move a lid by exactly one degree on purpose, and you cannot reproduce the movement later. So the decision logic is a pure Detector that takes (time, angle) samples, and the sensor is just one thing that feeds it. --simulate drives eight scripted timelines through the same object the app uses at runtime:

$ "Lid Creak.app/Contents/MacOS/LidCreak" --simulate

1° blip then held still                       fires=0  peak=6.7 °/s    PASS
1° every 300 ms (slow creep)                  fires=0  peak=3.3 °/s    PASS
12 °/s opening, High (needs 14)               fires=0  peak=12.0 °/s   PASS
3° tap in 150 ms, High (a tap, not a slide)   fires=0  peak=20.0 °/s   PASS
16 °/s sustained 2 s, High (gentle slide)     fires=2  peak=16.0 °/s   PASS
22 °/s opening, Normal                        fires=1  peak=22.0 °/s   PASS
80 °/s slam, Low                              fires=1  peak=80.0 °/s   PASS
20 °/s opening, Low (needs 35)                fires=0  peak=20.0 °/s   PASS

SIMULATE PASS

Read the first row against the old bug: the exact movement that used to fire at every setting now sits at 6.7 °/s — below the strictest threshold (High, 14) — and fails the travel gate before speed is even considered. Row four is the other half of the fix: a 3° tap genuinely moves at 20 °/s, and still doesn't creak at High, because a tap is not a slide.

--record exists for the opposite direction: it logs a real sensor trace so a movement that did creak can be replayed offline instead of argued about.

06The boundary assertions the build refuses to skip

build.sh runs these before it will call the build finished — every one sits on a boundary where an off-by-one hides:

1° travel @ 20 °/s, High (the old bug)        -> no   PASS
2.9° travel @ 80 °/s, High                    -> no   PASS
3.5° travel @ 45 °/s, Low                     -> yes  PASS
3.5° travel @ 20 °/s, Normal (too slow)       -> no   PASS
3.5° travel @ 25 °/s, Normal                  -> yes  PASS
12° travel @ 80 °/s, Low                      -> yes  PASS
8° travel @ 12 °/s, High (gentle, too slow)   -> no   PASS
8° travel @ 15 °/s, High                      -> yes  PASS
3.5° travel @ 45 °/s, disabled                -> no   PASS
3.5° travel @ 45 °/s, 0.5 s since last        -> no   PASS

10/10 PASS

Row two is the travel gate on its own: 80 °/s is a fast flop, but 2.9° of total movement means it never happened. Row eight is the retrigger cooldown doing its job.

07The audio path, since “mute it” was not the ask

The requirement was a creak you can hear, not a silenced app. The sample comes from FreeSlapMac's own pack; it is never modified in place:

  1. Find creaking.mp3 in FreeSlapMac.app, FreeSlapMac Silent.app, SlapMac.app or slapmac.app.
  2. Render the first 1.8 s to ~/Library/Application Support/LidCreak/creak.caf, once, normalised to −1 dBFS. The gain is measured from the clip's own peak, not hardcoded — the source peaks around 0.11 (≈ −19 dBFS), roughly a ×8 lift. Re-running is idempotent.
  3. Play with a per-move volume: 0.55 + speed / 120, clamped to 1.0 — a slow drag is quiet, a fast flop is full.

If none of those apps is installed the app still runs and still reads the sensor, but the menu item reads Test creak (no sound file) and the log says so, rather than failing silently. That honesty is the direct lesson from the 422 silent triggers.

08Install

1. unzip  →  Lid Creak.app
2. drag it to /Applications
3. right-click → Open   (ad-hoc signed, not notarised — a plain double-click is refused)
   or:  xattr -dr com.apple.quarantine "/Applications/Lid Creak.app"
        open "/Applications/Lid Creak.app"

Then: a window with the live angle, the measured speed and a switch. Settings live in defaults under com.isaac.lidcreak, so they survive a restart and can be scripted — defaults write com.isaac.lidcreak enabled -bool false turns it off from the terminal.

ziplidcreak-app-2.2.0.zip · 479,989 bytes sha256e9085d81b6fa84dd365ef174cec46c5f02ff9ac831be4a137a5d340597a48bc0 app704 KB bundle · 272,384-byte binary · arm64 sourcesrc/LidCreak.swift · 1,170 lines · 48,179 bytes verifiedextracted from the zip: signature valid · 8/8 checksums · --simulate PASS

09Honest limits