Triangulating My Cat Using Custom Sonar
Three broken smartphones, a 2kHz-to-18kHz chirp and GCC-PHAT cross-correlation at 192kHz gave 2cm positional accuracy on a calibration speaker. Then the cat refused to cooperate, and the fix was to stop transmitting entirely.
Pepper does not come when called. Pepper does not wear a collar, because Pepper removes collars with the patience of an escape artist and the indifference of a cat who has never once considered that the collar was for her benefit. So when I needed to know where Pepper actually was in a two-room apartment without getting up, the obvious answer wasn't a camera and it wasn't a tracker. It was sonar, built out of three broken smartphones I was otherwise going to throw away.
This is not a metaphor for anything. This is a genuine time-difference-of-arrival positioning system, the same category of math that underlies things like acoustic gunshot triangulation and seismic event location, repurposed to figure out which corner of the apartment a cat is currently ignoring me from, accurate to within about two centimeters.
Why Three Broken Phones Are Secretly a Sensor Array
Each of the three phones had a dead screen or a dead battery, but a functioning microphone and a functioning USB port, which is the only part of the hardware this project actually needed. Mount three microphones at known, fixed positions around a room, and you have the exact skeleton of a multilateration system: emit a sound, measure how long it takes to reach each microphone, and the differences in arrival time constrain the source to a specific point in space.
The core insight is that you don't need to know the absolute time the sound was emitted. You only need the differences in arrival time between pairs of microphones. That's the "difference" in time-difference-of-arrival, and it's what makes the whole approach viable with consumer hardware that has no synchronized absolute clock: three unrelated phone microphones, plugged into the same laptop, recording simultaneously into the same multi-channel audio interface, share a single sample clock the moment they're captured through one ADC. The absolute time each phone thinks it is doesn't matter. What matters is that all three channels are sampled in lockstep, so the offset between them, measured in samples, translates directly into a time offset, and time offset translates directly into distance.
The Ping
The actual "sonar" part needed a sound with a very specific property: a sharp, unambiguous onset that cross-correlation could lock onto precisely, rather than a smeared, ambiguous waveform where "arrival time" becomes a fuzzy guess. A pure tone is bad for this, it's too self-similar, cross-correlating it against itself produces a broad peak with no clean single answer. A linear frequency sweep (a chirp), sweeping from roughly 2kHz to 18kHz over a few milliseconds, gave a much sharper correlation peak, because a chirp doesn't correlate well with a time-shifted copy of itself except at the exact true offset.
import numpy as np
def generate_chirp(duration_s=0.01, f0=2000, f1=18000, sample_rate=192000):
t = np.linspace(0, duration_s, int(sample_rate * duration_s))
k = (f1 - f0) / duration_s
phase = 2 * np.pi * (f0 * t + 0.5 * k * t**2)
return np.sin(phase)
Playing this chirp from a fixed speaker at a known reference point let me first calibrate the system, before ever pointing it at a moving, uncooperative cat.
Sample Rate Is the Whole Ballgame
Here's the number that made this project genuinely hard rather than a weekend curiosity: sound travels at roughly 343 meters per second at room temperature. Two centimeters of positional accuracy means resolving a time difference of about 58 microseconds between microphones. At a standard 44.1kHz audio sample rate, each sample represents about 22.7 microseconds, which sounds almost sufficient until you remember that raw sample-level resolution alone won't get you a clean 58-microsecond distinction, you need sub-sample precision on top of it.
Recording at 192kHz instead of 44.1kHz brought each sample down to about 5.2 microseconds, giving enough raw resolution that sub-sample interpolation on the cross-correlation peak could reliably resolve differences well under 58 microseconds. Sub-sample precision itself came from parabolic interpolation around the peak of the cross-correlation function rather than just taking the single best-matching integer sample offset, fitting a small parabola through the peak and its two neighbors and solving for the true (non-integer) peak location.
def gcc_phat(sig, refsig, fs):
n = sig.shape[0] + refsig.shape[0]
SIG = np.fft.rfft(sig, n=n)
REFSIG = np.fft.rfft(refsig, n=n)
R = SIG * np.conj(REFSIG)
R /= np.abs(R) + 1e-10 # phase transform whitening
cc = np.fft.irfft(R, n=n)
max_shift = n // 2
cc = np.concatenate((cc[-max_shift:], cc[:max_shift+1]))
peak = np.argmax(np.abs(cc))
# parabolic interpolation for sub-sample peak
if 0 < peak < len(cc) - 1:
y0, y1, y2 = cc[peak-1], cc[peak], cc[peak+1]
peak += 0.5 * (y0 - y2) / (y0 - 2*y1 + y2)
return (peak - max_shift) / fs
GCC-PHAT, generalized cross-correlation with phase transform, was the specific algorithm that made this reliable in a real, non-anechoic apartment. Plain cross-correlation gets confused by reflections off walls and furniture, effectively hearing the same chirp arrive multiple times at slightly different delays and getting a muddled answer. The PHAT weighting whitens the spectrum before correlating, flattening out amplitude differences and emphasizing phase alignment, which made the correlation peak far sharper and far more resistant to a bounce off the kitchen cabinet being mistaken for the direct path.
From Time Differences to an Actual Position
With clean, sub-sample TDOA values between each pair of microphones, the remaining problem was pure geometry. Each TDOA measurement between two microphones constrains the source to lie on one branch of a hyperbola, the set of all points where the difference in distance to the two microphones equals the measured time difference times the speed of sound. Three microphones give three pairs, three hyperbolas, and the source position is the point where they all intersect.
In practice, measurement noise means the three hyperbolas don't intersect at a perfect single point, so this becomes a least-squares optimization instead of an exact geometric solve: find the (x, y) position that minimizes the squared error across all three pairwise TDOA constraints simultaneously.
from scipy.optimize import least_squares
SPEED_OF_SOUND = 343.0 # m/s, adjust for room temperature
def residuals(pos, mic_positions, tdoas, ref_idx=0):
x, y = pos
ref = mic_positions[ref_idx]
d_ref = np.hypot(x - ref[0], y - ref[1])
errs = []
for i, mic in enumerate(mic_positions):
if i == ref_idx:
continue
d_i = np.hypot(x - mic[0], y - mic[1])
predicted_tdoa = (d_i - d_ref) / SPEED_OF_SOUND
errs.append(predicted_tdoa - tdoas[i])
return errs
result = least_squares(residuals, x0=[2.0, 2.0], args=(mic_positions, measured_tdoas))
cat_position = result.x
The temperature correction on speed of sound turned out to matter more than expected. Room temperature swings a few degrees between morning and evening in a Korean apartment without central climate control running constantly, and 343 m/s at 20°C drifts to something meaningfully different at 26°C, enough to introduce centimeters of systematic bias if left uncorrected. A cheap temperature sensor feeding a simple correction formula into the speed-of-sound constant fixed a bias I'd initially mistaken for a calibration error in the microphone positions themselves.
The Part the Math Didn't Solve
Getting the algorithm to 2cm accuracy against a known, stationary calibration speaker was, in hindsight, the easy half of the project. Pepper is not a stationary calibration speaker. Pepper does not sit still for a chirp emission, has zero interest in remaining in one location while a Python script gathers three clean recordings, and reacts to a sudden 18kHz sweep with the exact suspicion you'd expect from an animal that can hear frequencies well past the edge of human perception.
The eventual workaround wasn't smarter signal processing, it was accepting a passive approach instead of an active one: rather than emitting a deliberate chirp and listening for its reflection or arrival, the system listens continuously for Pepper's own sounds, meowing, footsteps on the hardwood, the specific rustle of a very particular plastic bag she has strong opinions about, and runs the same GCC-PHAT TDOA pipeline against whatever transient sound she happens to generate. Less precise than a controlled chirp, since a meow has a much messier, less chirp-like waveform for cross-correlation to lock onto cleanly, but still reliably accurate to somewhere in the 5-10cm range on natural cat sounds, which is more than sufficient to answer the only question that actually mattered: which room, and roughly which corner.
Two centimeters was achievable. Getting a cat to cooperate with a controlled acoustic calibration procedure was not, and no amount of TDOA math was ever going to solve that particular optimization problem.