Case study

Race Timer

A standalone Wear OS app that runs a sailing start sequence on the wrist and sounds every signal within a stated bound of the mark.

Role
Sole developer: design, code, measurement, and testing on the water.
Stack
Kotlin, Jetpack Compose for Wear OS, AudioTrack, a pure-Kotlin timing module shared with a phone build.
Timeframe
July 2026 to now. Still in progress.
Links
Source on GitHub · Product page for sailors
The watch face mid-countdown: white numerals on navy, with Sync and Stop below

The problem

A sailing start is a countdown with sound signals at fixed marks. The countdown on the first build was never wrong: it read a monotonic clock and recomputed, so the digits were exact. The sound was wrong, and in a way nothing on the screen could show. The service found each cue by polling every 50 ms on the main thread, so every signal fired 0 to 50 ms late at random, and whatever the main thread was doing at that moment roughly doubled it. Across eleven one-second cues the worst gap was 212 ms off. A horn that far from the mark is a horn you cannot judge a line by.

Underneath that, the platform tone generator overshot its requested length and did so non-deterministically: 150 ms came out at 160, and a 500 ms request delivered 512 ms five times and 520 ms once in the same race. No padding can correct a number that will not hold still. And the audio output closed itself between blasts, charging about 54 ms to reopen on whichever blast came next, so a three-blast cue meant to sound 400 / 400 sounded 346 / 454. That one was found by a sailor's ear after the dispatch log had sworn it was even.

What I built

The app itself: six built-in sequences, a face that changes colour as the start runs down, a vibration pattern per signal matched to its sound, a sync to the nearest minute, restore after the process dies, and two race-committee modes that keep counting after the gun. A phone build shares the timing code and exists unpublished.

The part that mattered is where the rules live. The timing engine is a pure-Kotlin module with no Android in it, so every timing rule has a unit test CI runs; the watch and phone modules never name each other. Every fix that started as a line in the service moved into that module once it was clear that a rule left inline had stayed wrong for two weeks while every call reported success. As of August the shared module ran 367 tests.

The other half was instrumentation. Nothing here was tuned by ear alone, because three instruments once reported success on a watch that was making no sound at all. The service logs how far each cue fired from its mark, the audio layer logs how late its buffer went out and how many frames the hardware consumed, and a full race on the wrist is the unit of measurement rather than a single cue.

A decision I’d defend

Nothing on a cue’s deadline is allowed to do work. Not poll, not synthesise, not call the audio server. Everything a cue needs is done before the deadline, and the deadline itself is a single buffer write onto a track that is already running.

Concretely, that is three things. The engine exposes when its next cue is due and the service arms a timer on that exact boundary; the 50 ms poll survives only to drive the display and to backstop a missed wake-up. Each cue is rendered ahead of time into one PCM buffer that carries its tones and its silences, so no blast can pay an output restart in the middle of a cue. And the track is started once before the race and never paused, with a 10 ms silence written every 300 ms so the mixer does not drop a starved track and hand the restart cost back to the next write.

That last piece is the one I would defend hardest, because a negative control without it passed the story’s own acceptance criterion. With the heartbeat off, the first cue of a race was 40 ms late, under the 50 ms the criterion asked for, while every cue after a gap of ten seconds or more paid 6 to 88 ms on the write. A criterion written as a threshold on one cue could not price the mechanism; only the full race could.

What it rejected, and why:

The cost is honest. The audio path is more code than a single tone call. The always-running track needs an API level the app’s minimum does not guarantee, so older watches keep the flush-and-play path and its first-cue penalty. And a cue written while a heartbeat chunk is draining can sound up to 10 ms late; that is reported rather than corrected, because writing a cue early risks sounding a signal before its mark, which is the one error a sailor cannot recover from.

One more thing had to be true for any of this to hold: the CPU has to be awake. The scheduled timers run on a clock that stops in suspend, so a single partial wake lock sized to the race is load-bearing for every cue. It was sized once at start, and a sync to the minute could lengthen the race by up to 30 s without re-sizing it; two round-up syncs spent the whole margin and the lock expired with no callback. Re-arming it on every sync fixed that, and the fix was measured with the screen off from start to gun rather than assumed.

Outcome

Measured on one watch, 150 cues across five complete US Sailing sequences, cold and warm process, both audio routes:

Distance from the mark, worst case over 150 cues
WhatBeforeAfter
Dispatch±212 ms61 ms
Tone onset, including a deliberate 40 ms lead-in—101 ms
Cues delivered short—0 of 150
Screen off, start to gun, 30 cues—max 4 ms, 0 ms end to end

The medians are single digits, and this page quotes the worst case on purpose. A case study is read as a guarantee, and a bound stated at the observed maximum is the one the next device falsifies; the app’s own documentation claims 100 ms dispatch and 150 ms tone, with the headroom deliberate. The worst run was the fifth, the one picked up and worn for the by-ear check. The four left on the charger were the optimistic tail.

Nothing in the project times sound leaving the speaker. That last gap is closed by ear: a full sequence on the wrist, blast and buzz arriving as one event.

Status: on Google Play’s internal testing track since August 2026, with signing and upload run from CI on a tag. A public listing needs a closed test with twelve testers over fourteen days, which has not started. Still in progress.

Sailors wanting the app rather than the engineering are better served by the product page on madcowsailing.com.