Measuring a cat
The plan was simple: make the trackpad purr. The interesting part was that the frequency I needed was almost entirely missing from the recording I started with.
A MacBook trackpad is not a speaker, but it is a very good actuator. The Taptic Engine underneath is a linear resonant motor, and macOS exposes it, undocumented, to anything willing to link against a private framework. Fire it repeatedly and the whole glass surface moves. Fire it at the right rate and it stops feeling like a notification and starts feeling like an animal.
So the entire problem reduces to one number: what rate?
The fundamental was not in the recording
I started with a field recording of a cat purring. The obvious approach is to take an FFT and read off the loudest peak. That gives you 78 Hz, and 78 Hz is wrong.
It is wrong because a purr is a harmonic stack, and 78 Hz is the third harmonic. The true fundamental sat around 26 Hz, and almost nothing in the recording chain wanted to keep it: microphones roll off down there, and this recording had clearly been high-passed. Everything below 23 Hz sat flat at roughly 1.5% of total energy. The note I was looking for had been filtered out of the file before I ever opened it.
The fix is to stop looking for the peak and start scoring candidates by what they imply. For every plausible fundamental, sum the energy found at its integer multiples, and rank. Under that scoring the winner is unambiguous:
candidate harmonics land at score
25.75 Hz 52, 77, 103, 129, 154 Hz highest
Every one of those lands on an observed peak in the spectrum. The fundamental was inferable from its own harmonics even though it was practically absent itself, which is a satisfying thing to watch fall out of a scoring loop.
And then the free lunch: a haptic tap is not a sine wave, it is a broadband impulse. Firing impulses at 25.75 Hz regenerates the entire harmonic stack for nothing. I did not have to synthesise 52 and 77 and 103 Hz. I just had to get the timing right and the physics filled in the rest.
Real purrs do not repeat, and that turns out to matter
My first working version sounded like a phone buzzing on a table. It was at the right frequency, and it was obviously synthetic.
So I went back to the recording and looked for the breath envelope, the slow swell and fade as the cat inhales and exhales, expecting to find a period I could loop. There isn't one. I ran autocorrelation over the amplitude envelope and the results were emphatic:
| Candidate period | Score |
|---|---|
| strongest | 0.33, too weak to trust |
| second | 0.12 |
| third | 0.06 |
| fourth | 0.01 |
There is no loop in a purring cat. A clean rise-hold-fall cycle reads as machinery precisely because it repeats, and the ear and the palm both notice within about two cycles.
The app therefore regenerates each breath: every cycle gets a fresh random length and a fresh peak amplitude, within bounds. Two parameters, and they are the difference between a cat and an alert.
The hardware lies, politely
The private framework takes a strength id per tap. The documentation for this is, of course, nothing at all, so I wrote a probe that walked every value and recorded what actually happened. Two surprises.
Accepted is not actuated. Strengths 1 through 6 produce output. Ids 15 and 16 are accepted, returning success, and produce no movement whatsoever. Everything else in 1 to 20 is refused outright. If you trust the return value you ship a silent app to a subset of users.
They are not in intensity order. Sitting with a hand on
the trackpad and ranking them by feel, id 4 is stronger than id 5. The app
ships a hand-ranked ladder, [1, 2, 3, 5, 4, 6], because the
numbers are identifiers and I had been reading them as magnitudes.
The good news: no rate limiting anywhere between 10 and 80 Hz. Zero pulses refused, timing accurate to 0.1 Hz. Whatever this interface is for, it does not mind being asked 26 times a second forever.
Volume, without a volume control
There is no amplitude parameter. A tap is a tap. So loudness has to come from density: to play at 60% you drop 40% of the pulses.
My first attempt used a modulo test, and it audibly hiccups, because at some ratios modulo drops two adjacent pulses and you feel the gap. The fix is error diffusion, the same trick as dithering an image: accumulate the desired intensity, emit a pulse when the accumulator crosses one, subtract. Drops end up spread evenly instead of clustered, and the hiccup disappears.
accumulator += intensity
if accumulator >= 1 {
accumulator -= 1
actuator.tap(strength)
}
Three bugs that cost real time
Touch readings latch. The multitouch driver emits frames on change, not continuously. Poll the finger count and it returns the last value forever after you lift your hand, so the app believed a hand was resting there indefinitely. The fix is to treat any frame older than 250 ms as no contact, because "no frames arriving" is the signal.
There is more than one multitouch device. This Mac enumerates a 22x30 sensor and a 15x10 one. Watching both meant phantom contacts roughly 9% of the time with nobody near the machine. Only the largest sensor is the trackpad. Amusingly, the phantom has no actuator, which is the only reason a different piece of device-selection code had never picked the wrong one.
SwiftUI republishes identical values. The behaviour
state machine ticks about seven times a second and mostly reassigns the
same state. @Published fires on every assignment, equal or not,
so the whole menu bar scene re-rendered at that rate: 9% of a core, doing
nothing, forever. Comparing before publishing took it to 0.5%.
Making it a cat instead of a vibrator
The frequency work is what makes it feel real. What makes people keep it is that it refuses.
Rest your hand and the app rolls against a willingness value. Usually it purrs. Sometimes it does not, and then it sulks for a while, and poking it during the sulk makes the sulk longer. That single refusal is the most commented-on behaviour in testing, and it is four lines of logic.
The rest followed from taking the metaphor seriously rather than from any product plan: a different temperament each day, a trust that deepens over about three weeks, rare unexplained zoomies, small gifts left in the menu bar. And manners, which turned out to be the one people thank you for: automatic purring stops whenever any microphone or camera goes live, so it never buzzes through a call.
Close the lid on an external display and it naps, because I checked and the actuator is a hardware no-op with the lid shut. There was no point spinning a pulse thread nobody could feel.
What it costs
About half a percent of one core while purring, and under 3 MB resident. Roughly 10 ms of CPU per 6 seconds of purring. It is a menu bar app that does one physical thing, and it should be embarrassed to cost more.
You cannot film it
The honest problem with shipping this: there is no demo video, and there cannot be one. A video is a hand resting on a trackpad for thirty seconds. The product is entirely in a place a camera cannot go.
So the site draws the pulse train instead, live, from the same numbers the app sends to the hardware, and otherwise asks you to just try it. It is free, it is about a megabyte, and either your palm agrees or it doesn't.