A command-line WWV time code decoder and system clock synchroniser written in C++17.
Receives the NIST WWV shortwave time signal through a connected radio (or any audio input), decodes the BCD time code from audio in real time, displays current UTC with a live status panel, and optionally sets the system clock once a configurable number of consecutive frames have been verified.
Status: early alpha. Decodes reliably from clean recordings, live reception under good propagation, and HF signals degraded by severe flutter fading.
- Signal format — WWV
- Theory of operation
- Building
- Usage
- Configuration
- Reading audio files (ffmpeg)
- What works
- Known limitations
WWV (Fort Collins, Colorado) transmits on 2.5, 5, 10, 15, and 20 MHz. Every second begins with a 5 ms 1 kHz sine-wave "tick" at the precise on-time point, followed by a 100 Hz subcarrier burst whose duration encodes one BCD bit:
| Duration | Bit type | Meaning |
|---|---|---|
| 200 ms | 0 (ZERO) |
BCD data zero |
| 500 ms | 1 (ONE) |
BCD data one |
| 800 ms | M (MARKER) |
Position marker |
One complete frame is 60 bits (one per second, one frame per minute). Position markers occur at seconds 0, 9, 19, 29, 39, and 49 within each frame and are used by the decoder for frame alignment.
The BCD fields encoded in the 60 bits are (by second within the frame):
| Bits | Field |
|---|---|
| 1–8 | Minutes (BCD, tens then units) |
| 10–18 | Hours (BCD) |
| 20–28 | Day of year (hundreds) |
| 25–33 | Day of year (tens + units) |
| 38, 40–43 | UT1 correction (±0.1 s steps) |
| 45–48 | Year tens (BCD) |
| 50–53 | Year units (BCD) |
| 55–56 | DST status |
| 57 | Leap-second warning |
See NIST SP 432 for the complete specification.
Rather than FFT, the decoder uses Goertzel filters — single-frequency DFT evaluations — at exactly 1 kHz and 100 Hz over 10 ms blocks (480 samples at 48 kHz). This gives a power measurement at each target frequency every 10 ms without the overhead of a full FFT.
Each 10 ms block's 1 kHz Goertzel power is smoothed with an exponential moving average. A rising edge that exceeds 100× the tracked noise floor is classified as a tick (the on-time second marker), subject to an anti-spoof gate: genuine 1 kHz ticks occur before the 100 Hz subcarrier begins (~30 ms later), so the 100 Hz Goertzel power at tick time is near the background floor. MARKER-subcarrier harmonics at 1 kHz (10th harmonic of 100 Hz) are rejected because they occur simultaneously with elevated 100 Hz power. A 950 ms lockout after each tick also prevents false re-triggers from multipath echoes.
Once a real tick is detected, subsequent second boundaries are free-running predictions: the decoder advances the phase by exactly 100 blocks (1000 ms) each second regardless of whether a real tick arrives. WWV's cesium clock is accurate to microseconds; the audio clock drifts less than 1 ms per minute at 50 ppm, so predictions remain accurate across many missed ticks.
Rather than detecting a continuous burst onset/offset, the decoder accumulates raw 100 Hz Goertzel power across three fixed sub-windows after each tick:
| Window | Blocks after tick | Audio time | Blocks |
|---|---|---|---|
| Early | 3–22 | 30–230 ms | 20 |
| Mid | 23–52 | 230–530 ms | 30 |
| Late | 53–82 | 530–830 ms | 30 |
Classification fires at block 83 (830 ms post-tick):
total = e_early + e_mid + e_late
if total < 2.0 × noise_ref → BIT_MISSING (no detectable signal)
elif e_late / total > 0.28 → BIT_MARKER (energy extends to 830 ms)
elif e_mid / total > 0.28 → BIT_ONE (energy extends to 530 ms)
else → BIT_ZERO (energy confined to 230 ms)
The noise reference is a rolling EMA of the 100 Hz power measured in a quiet tail window at 920–970 ms after each tick, scaled to the 80-block measurement span.
This approach is robust to HF flutter fading: when the subcarrier arrives as 10–70 ms incoherent fragments rather than a sustained burst, the fragments still deposit energy into the correct sub-window. Brief gaps between fragments do not reset the accumulator, so the full bit period contributes to the classification.
Decoded bits are written into a 120-entry ring buffer (two minutes of history). When
a second is skipped due to HF fading, a BIT_MISSING placeholder is inserted so the
ring buffer keeps correct 1-second cadence. The frame decoder tolerates up to 30
missing bits per frame and up to one missing marker position.
After 60 bits are accumulated the decoder tries all possible 60-bit starting positions in the ring buffer. A candidate frame is accepted when:
- All six marker positions contain
BIT_MARKER(or at most one containsBIT_MISSING;BIT_ZEROorBIT_ONEat a marker position is a hard alignment failure). - "Always-zero" positions defined by the NIST spec do not contain
BIT_MARKER. - The decoded minute (0–59), hour (0–23), day of year (1–366), and year (0–99) are all in valid range.
Once a valid frame is decoded, currentUtcPoint() extrapolates forward from the P0
tick timestamp using std::chrono::steady_clock, preserving sub-second precision via
tv_usec / FILETIME microseconds when setting the system clock. Audio pipeline
latency (ADC buffer + OS driver + PortAudio layer) is read from
Pa_GetStreamInfo()->inputLatency after the stream opens and compensated
automatically. Practical accuracy under typical conditions is ±50 ms, bounded by
the 10 ms Goertzel block resolution.
| Library | Purpose | Package (Debian/Ubuntu) |
|---|---|---|
| PortAudio | Live audio capture | libportaudio19-dev |
| Hamlib | Radio CAT control | libhamlib-dev (optional) |
sudo apt install cmake build-essential libportaudio19-dev
sudo apt install libhamlib-dev # optional, enables --list-rigs and rig controlcmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j2The binary is build/skyclock. No install step is required to run it.
Pre-built releases are available in the repository:
| File | Platform |
|---|---|
skyclock-VERSION-x86_64.AppImage |
Linux x86-64 (self-contained) |
skyclock-VERSION-rpi-aarch64.tar.gz |
Raspberry Pi (64-bit OS) |
skyclock-VERSION-windows-x64.zip |
Windows 10+ x64 |
The Windows zip includes the required runtime DLLs (libportaudio.dll,
libgcc_s_seh-1.dll, libstdc++-6.dll, libwinpthread-1.dll). Extract and run
skyclock.exe from any directory.
skyclock # decode from default audio device
skyclock --device <name> # use a named PortAudio input device
skyclock --file <path> # fast decode from audio file (debug)
skyclock --file <path> --realtime # real-time file simulation (see below)
skyclock --list-devices # list audio input devices and exit
skyclock --list-rigs # list hamlib rig models (requires hamlib)
skyclock --version # print version and exit
skyclock --help # this help
Tune your radio to 10 000 kHz AM, connect audio output to the PC sound card input, then run:
./build/skyclockA 3-line status panel updates in place while listening:
skyclock 0.1.1-ALPHA ████████████░░░░░░░░ 60% 10000 kHz
[|.#.##.#.|.##.##.#.|.... ] 22 bits
Searching for WWV signal...
The bracket on line 2 shows the last 60 classified bits as a sliding window:
. = ZERO, # = ONE, | = MARKER, ? = MISSING.
Once a frame decodes, lines 2 and 3 update:
skyclock 0.1.1-ALPHA ████████████░░░░░░░░ 60% 10000 kHz
2026-04-02 19:23:45 UTC Day 092 UT1 -0.3s [conf: 3]
LOCKED [conf: 3 / 2 needed]
After the configured number of consecutive valid frames (minConfidence), the system
clock is set and line 3 changes to LOCKED — clock set successfully.
The status panel is only drawn when stdout is a TTY; plain scrolling text is used otherwise (pipes, log files, etc.).
Settings are stored in ~/.skyclock/settings.json, created with defaults on first
run.
| Key | Type | Default | Description |
|---|---|---|---|
rigEnabled |
bool | false |
Enable hamlib radio control |
rigModel |
int | 1 |
Hamlib rig model number |
rigPort |
string | /dev/ttyUSB0 |
Serial port for CAT control |
freqKhz |
int | 10000 |
WWV frequency to tune (kHz) |
rigMode |
string | "AM" |
Radio mode string |
audioDevice |
string | "" |
Audio device name substring (empty = default) |
setSystemClock |
bool | false |
Set system clock after decode (needs root) |
minConfidence |
int | 2 |
Consecutive valid frames before setting clock |
WWV transmits on 2 500, 5 000, 10 000, 15 000, and 20 000 kHz. 10 000 kHz is the most reliable frequency across North America during daylight hours.
The --file mode decodes audio files via ffmpeg, which handles any format ffmpeg
understands (MP3, OGG, Opus, FLAC, WAV, …) and resamples to 48 kHz mono float32 on
the fly. ffmpeg must be installed and in PATH:
sudo apt install ffmpegskyclock --file recording.opusDecodes as quickly as the CPU allows. Bits are printed as they are classified and
frame decodes are prefixed with ==>. Useful for testing and debugging signal files.
skyclock --file recording.opus --realtimePaces audio playback at 48 kHz real-time speed (10 ms chunks with 10 ms sleeps), showing the same live status panel as radio mode. Use this to test the display pipeline or to simulate a live reception session from a recording.
- Clean recordings and strong live signals decode within 1–2 minutes of accumulated bits.
- HF flutter fading — signals that arrive as 10–70 ms incoherent fragments due to severe ionospheric fading — are handled by the energy integration classifier. Each fragment deposits its 100 Hz energy into the correct sub-window; the total still classifies the bit correctly even when no individual fragment is long enough to trigger a threshold-based detector.
- Isolated HF dropouts (single missed seconds) are filled with
BIT_MISSINGplaceholders; the frame decoder tolerates up to 30 per frame and 1 missing marker position. - False ticks from 100 Hz MARKER harmonics are rejected by the anti-spoof gate: genuine ticks have near-floor p100 (subcarrier hasn't started yet); harmonic false triggers have simultaneous elevated p100 and p1k. The 950 ms lockout provides additional protection.
- Sub-second clock accuracy: audio pipeline latency is read from PortAudio and
compensated automatically; the system clock is set with microsecond precision via
tv_usec/ FILETIME. Practical accuracy is ±50 ms under typical conditions.
In some recordings the 1 kHz tick at second 0 of each minute (P0) is too weak to detect. The decoder allows one missing marker per frame, but confidence cannot build through P0 if it is consistently absent. Future work: infer P0 from the established P1–P5 inter-marker interval.
WWVH (Hawaii) transmits on the same frequencies as WWV. Both stations are receivable across much of North America on 10 and 15 MHz. The decoder does not distinguish between them; mixed reception can produce garbled frames. Tuning to 5 MHz (WWV only) typically gives the cleanest single-station decode.
The hamlib rig-control path exists in the code but has not been exercised with a physical radio. Contributions of tested rig configurations are welcome.