Building a Timegrapher From a Phone Microphone

12 min read Original article ↗

March 2026

A professional timegrapher costs $500 to $3,000. It clamps a piezoelectric sensor against a watch case, listens to the escapement, and tells you how many seconds per day your watch gains or loses. The sensor provides maybe 30 dB of signal-to-noise ratio. The measurement takes five seconds.

I wanted to do the same thing with a phone microphone. An iPhone's bottom mic, pointed at a watch sitting on a table, gives you about 1.5 dB of SNR. That is not a typo. The tick of a mechanical watch, picked up through air by a commodity MEMS microphone, is barely distinguishable from the noise floor.

This is the story of how I made it work anyway.

What a timegrapher actually measures

A mechanical watch's escapement produces a repeating pattern of sounds. The balance wheel swings one direction — the pallet fork releases an escape wheel tooth, the next tooth drops onto the opposite jewel, and the fork slams into its banking pin. That's a "tick." The balance wheel swings back and the whole thing happens in reverse. That's a "tock."

At 28,800 beats per hour — the most common modern frequency — you get one of these impulses every 125 milliseconds. Each impulse is a cluster of mechanical events that blur together into a broadband transient about 1-2 ms wide, with most energy between 4 and 15 kHz. The rest of those 125 ms is silence.

ticktockticktockticktock250 ms (one full oscillation at 4 Hz)122 ms128 ms

Raw audio from a 28,800 BPH watch. Each spike is a tick or tock impulse, spaced 125 ms apart. One full oscillation of the balance wheel takes 250 ms. The slight asymmetry between tick-to-tock (122 ms) and tock-to-tick (128 ms) intervals is the beat error — 3.0 ms in this example.

A timegrapher extracts three things from this signal. Rate is how far the average beat period deviates from the ideal — expressed as seconds per day. At 28,800 BPH you get 8 beats per second, each 125 ms apart, with a full balance wheel oscillation every 250 ms. A watch that's 10 seconds fast per day has beats spaced at 124.986 ms instead of 125.000 ms. Beat error is the difference between the tick-to-tock and tock-to-tick intervals, caused by misalignment of the impulse pin. Amplitude is the arc of the balance wheel's swing, inferred from timing details within each impulse cluster.

The 1.5 dB problem

Before writing a single line of DSP code, I needed to know what the hardware could give me. I ran four parameter sweeps across seven iOS audio session configurations on an iPhone 15 Pro — varying the audio session mode, category, microphone selection, AGC, gain, echo cancellation, and noise suppression. Every permutation produced between 1.2 and 1.8 dB of SNR.

The biggest lever was AGC. Automatic gain control added +1.6 dB versus AGC disabled. Switching from playAndRecord to record category gained +1.1 dB but blocked audio output (no haptic feedback). measurement mode — which Apple's documentation suggests for "accurate audio" — actually disabled AGC and produced the worst SNR. inputGainSettable returned false on my test device. The hardware gain knob doesn't exist.

I also discovered that connected AirPods silently override the microphone selection. iOS routes audio input to AirPods regardless of setPreferredInput(builtInMic). Every configuration produced identical 1.2 dB with AirPods connected. I didn't figure this out for an embarrassingly long time.

So: 1.5 dB of raw SNR, no hardware path to improve it, and I needed enough precision to resolve sub-microsecond timing differences. The path forward was entirely DSP.

The processing pipeline

Every second, I take the rolling audio buffer and run it through a pipeline on a background isolate. The entire DSP stack is pure Dart — no native FFI, no platform-specific signal processing. Portable, debuggable, and fast enough at 44.1 kHz.

Microphone Input44.1 kHz PCM-16 monoBandpass Filter3 kHz HPF + 12 kHz LPFEnvelope Detectionmoving average of |signal|Epoch Foldingsweep 5 BPH candidates, stack & averageAutocorrelation Refinementsub-sample precision via parabolic interpPeak Detection & Tick Validationadaptive threshold, rhythm filteringRate Calculation + Kalman Filterconverged s/day estimate

The complete processing pipeline, from raw microphone input to converged rate estimate. Highlighted stages represent the two key innovations: epoch folding for SNR gain, and Kalman filtering for convergence.

Bandpass filtering

Watch tick energy peaks between 5 and 15 kHz. Below that you get HVAC rumble, speech, handling noise. Above 12 kHz, signal energy drops off and you're mostly amplifying hiss. I use a 2nd-order Butterworth high-pass at 3 kHz and a 2nd-order Butterworth low-pass at 12 kHz — a design borrowed from vacaboja/tg, the most complete open-source timegrapher implementation.

watch tick energy-10-20-30-400 dB5001k3k5k10k20kFrequency (Hz)HPF 3 kHzLPF 12 kHz

The bandpass filter's frequency response. The 3-12 kHz passband is aligned with the spectral energy of mechanical watch tick impulses. Everything outside gets attenuated.

Envelope extraction

After filtering, I compute the signal envelope — a smooth curve that traces the amplitude peaks. Take the absolute value of the filtered signal and apply a moving average with an 8 ms window (352 samples). This captures the sharp transient of each tick while smoothing out the inter-tick noise. The envelope is what I actually run peak detection on.

Epoch folding: the key insight

Here is where 1.5 dB becomes 21 dB.

Epoch folding is a technique from radio astronomy, where it's used to detect pulsars — periodic signals buried in noise. The idea is simple: if you know (or can guess) the period of a repeating signal, you can chop your data into segments of that length and average them together. Random noise cancels out. The periodic signal adds constructively.

The math is straightforward. If you stack N periods, your signal amplitude stays constant while your noise amplitude drops by 1/√N. SNR improves by 10 · log10(N) dB. With 100 ticks, that's +20 dB. With 200 ticks, +23 dB.

Raw signal (low SNR)epoch 1epoch 2epoch 3}fold... repeated across 100+ ticks ...Folded average (high SNR)clear tick peak

Epoch folding in action. Each raw epoch (top) contains a tick buried in noise. After folding and averaging across 100+ epochs (bottom), the tick emerges clearly. This is the same technique used to detect pulsars.

I don't know the period in advance. So I sweep. I test five standard beat-per-hour candidates — 18,000, 21,600, 25,200, 28,800, and 36,000 — by folding the envelope at each candidate period and scoring the result. The score is the peak-to-mean ratio of the folded template. The candidate with the highest score wins. A score above 1.5 indicates a real signal.

This replaced my earlier approach of requiring users to input their watch's BPH. Epoch folding reliably identifies the correct frequency on both 3 Hz and 4 Hz watches without any user input. It works because even at 1.5 dB, with enough ticks to fold, the periodic component dominates the average.

Autocorrelation and the harmonic trap

Once epoch folding identifies the approximate period, I refine it with autocorrelation — computing how well the signal correlates with a time-shifted copy of itself. The lag at which correlation peaks gives the precise beat period.

I apply parabolic interpolation around the autocorrelation peak to get sub-sample precision. At the downsampled rate of ~8 kHz, one sample is 125 μs. Parabolic interpolation gets me to ~15 μs, which corresponds to roughly 0.3 s/day in rate precision. Plenty.

But autocorrelation has a trap at low SNR: it finds harmonics before fundamentals. A 4 Hz watch produces a tick-tock pattern where the energy repeats at the tick rate, the tock rate, the half-rate, and various beat harmonics. At 1.3-3.8 dB SNR, my autocorrelation consistently peaked at ~5.5 Hz for a 4 Hz watch — locking onto a harmonic of the tick-tock interleaving pattern rather than the fundamental oscillation frequency.

Epoch folding solves this because it tests against known physical frequencies. No watch runs at 5.5 Hz. By the time autocorrelation runs, epoch folding has already identified the correct frequency, and I constrain the autocorrelation search to ±5% of the epoch fold result. This prevents sub-harmonic and super-harmonic hijacking entirely.

From ticks to seconds per day

The rate formula is:

rate_spd = 86400 × (measuredHz - snappedHz) / snappedHz

Where measuredHz is the precision-refined frequency and snappedHz is the nearest standard beat frequency. If the measured frequency is 4.00023 Hz and the standard is 4.00000 Hz, the watch gains 86400 × 0.00023 / 4.0 = +5.0 seconds per day.

Beat error comes from separating even and odd inter-beat intervals — the tick-to-tock gaps versus the tock-to-tick gaps — and taking half the difference of their medians. A perfect escapement has zero beat error. Anything under 0.5 ms is generally fine. I gate this measurement behind sufficient evidence (SNR, tick count, autocorrelation strength) because at low signal levels the even/odd classification is unreliable.

Making it converge

Raw rate estimates jump around. One second you measure +3.2 s/day, the next +11.7, the next -1.4. This is expected — each estimate uses a short window and the signal is noisy. The question is how to converge on the true value quickly enough that a user doesn't lose patience.

I use a Kalman filter with adaptive measurement noise. The measurement noise parameter R is computed from three factors: the standard deviation of inter-beat intervals (jitter tells you signal quality), the DSP confidence score, and the raw SNR. When the signal is weak, R is large and the filter moves slowly — it doesn't overreact to garbage. When the signal is strong, R drops and the filter converges fast.

An earlier version used the Kalman posterior variance to decide when to stop. This works great in textbooks. On real devices, with R ≈ 900 (typical for low-SNR sessions), the posterior variance converges to a steady-state of ~30 — making any reasonable "good enough" threshold unreachable. I switched to tracking the standard deviation of the Kalman filter's own outputs. This measures what the user actually sees: is the displayed rate still jumping around, or has it settled? Typical converged SD is 0.3-1.0 s/day.

The state machine

A measurement session walks through four states: listening (mic open, accumulating samples), detecting (signal found, rate estimates appearing), locked (beat rate confirmed, window anchored), and done (converged or timed out). Each transition has specific evidence requirements.

Beat locking requires three consecutive snapshots agreeing on the same standard BPH (four for high-beat watches above 4.5 Hz, which are harder to distinguish from harmonics). Once locked, the analysis window anchors at the lock point and grows outward, and the autocorrelation search narrows from ±5% to ±2%. If the watch has a known BPH stored in its metadata, I skip the confirmation dance entirely and pre-lock at session start.

Auto-stop fires when the display rate SD drops below 1.5 s/day with strong signal (fast path), or below 3.0 s/day for two consecutive seconds (standard path). Most sessions with good placement converge in 10-15 seconds. The hard timeout is 30 seconds — if it hasn't converged by then, it won't.

What I learned from extensive device testing

Positioning dominates everything. Two back-to-back sessions with the same watch: one detected zero ticks (1.5 dB SNR), the other detected 65 ticks (4.2 dB SNR). The only difference was where the watch sat relative to the microphone. A few millimeters matter. This is the single biggest source of measurement failure, and no amount of DSP can fully compensate for it.

State plumbing failures look like DSP failures. I spent a full debugging session on a "signal processing bug" that turned out to be the BPH hint never reaching the DSP layer because the setup function wasn't called at the right lifecycle point. Another session was spent debugging "unreliable detection" that was actually a test watch that didn't have its BPH set in the database. I've learned this the hard way: verify your data assumptions before blaming the algorithm.

Every terminal state must be visible to the UI. An early version silently discarded low-confidence results and transitioned to... nothing. The provider sat in a state the UI didn't handle. The user saw "Refining..." forever. Every exit path — success, failure, timeout, low confidence — needs to produce a state that the UI explicitly renders.

Live rate beats one-shot analysis. I originally ran a "finalize" step at session end — a full DSP pass on the complete buffer for a definitive result. Testing showed the live Kalman-filtered rate was 9-19 s/day more accurate. The overlapping windows and progressive refinement of the live pipeline simply produce better estimates than a single long-window analysis. I removed finalize entirely.

Honest limitations

This is not a replacement for a professional timegrapher. It's worth being specific about where it falls short.

AspectPhone micProfessional timegrapher
Rate accuracy±2-5 s/day±0.1-0.5 s/day
Beat errorApproximatePrecise
AmplitudeEstimated (requires lift angle)Directly measured
Convergence time10-15 seconds3-5 seconds
Quiet movementsMay failContact sensor handles it
EnvironmentNeeds quiet roomImmune to ambient noise
Cost$4.99$500-3,000+

Very quiet movements — some Seiko 4R calibers, some vintage pieces — may not produce enough acoustic energy for reliable detection through air. A quiet room is essential; a coffee shop won't work. Android support faces additional challenges because the platform doesn't allow forcing unprocessed audio input on all devices, and audio latency varies widely across the hardware landscape.

But for the core use case — a watch collector sitting at a desk who wants to know if their daily wearer is running +5 or +15 — it works. You put the watch down, tap a button, and ten seconds later you have a number you can trust within a few seconds per day. That's useful information that previously required equipment costing 100x more.

ChronoLog's audio timegrapher is available now on iOS as part of the 2.0 release. The Android 2.0 version is currently in open testing — you can join and get early access on Google Play. Try it on your watches and let me know how it goes.