Real-Time Pacing & Timestamping¶
doppler can emit samples at their real sample rate — mimicking a hardware
sample clock — and timestamp them on an ideal timeline. Both come from one
small C core, timing_core (dp_sample_clock_t), exposed as the
wfmgen --realtime CLI flag and the Python
SampleClock.
This guide explains why software pacing is more subtle than sleep(N/fs),
how doppler does it drift-free, and where it fits — including the role of the
virtual-memory ring buffer.
The problem: fs is normally just metadata¶
A generator produces samples as fast as the CPU allows. The sample rate fs
only labels the data — it sets the BLUE xdelta, the ZMQ header's
sample_rate, the SigMF core:sample_rate. Nothing actually waits.
That's correct for writing a file: you want it done now. But when a consumer
expects real time — a live spectrum display, a receiver under test, an SDR
playback emulation — flooding it as fast as possible is wrong. You need each
block to leave on the clock: block k of N samples at time k·N/fs.
Why sleep(N/fs) per block drifts¶
The obvious loop accumulates error:
sleep oversleeps by an OS-scheduler-dependent slice every iteration, and you
never charge for the time send itself took. Those errors compound: the
effective rate is always a little under fs, and the gap grows without bound.
After a million blocks you can be seconds behind.
The fix: absolute-deadline scheduling¶
Anchor an epoch once, then derive every deadline from the cumulative sample count against that fixed epoch — never from summed sleeps:
Sleep until that absolute instant. An over- or under-sleep on one block is
corrected on the next, because the next deadline is computed fresh from n,
not added to the last one. This is what dp_sample_clock_pace does
(clock_nanosleep(TIMER_ABSTIME) on Linux, a portable nanosleep-to-remainder
loop elsewhere). The long-run rate is exactly fs; the only residual error
is per-block jitter bounded by the OS scheduler, and it averages to zero.
Average-rate, not sample-accurate
Software on a non-realtime OS gives a drift-free average rate with bounded jitter — never true sample-clock fidelity. A real sample clock is a hardware oscillator clocking a DAC out of a FIFO; software's only job there is to keep the FIFO fed. So "mimic a sample clock" in software means: emit at the correct average rate and carry exact timestamps so the consumer can reconstruct the intended clock.
Pacing — CLI¶
wfmgen --realtime throttles the emit loop to fs. Pacing does not alter
the samples; a file written with and without --realtime is byte-identical.
# Stream QPSK to a live receiver at the true 1 MS/s, not as fast as possible
wfmgen --type qpsk --fs 1e6 --sps 8 --continuous --realtime \
--output zmq://tcp://*:5555
If the producer can't keep up — a block takes longer than its N/fs period, an
underrun — wfmgen keeps the absolute timeline and prints a summary to
stderr at exit (wfmgen: 3 underrun(s) — worst 1.2 ms behind real time). Use
--realtime-resync to re-anchor to "now" on each underrun instead (staying near
real time at the cost of an inserted gap).
Pacing — Python¶
The one-liner equivalent of --realtime is Composer.stream(block, realtime=…), a generator that paces each block as it yields it (realtime=True
uses the first segment's fs; pass a float to set the rate):
from doppler.wfm.compose import Composer, ZmqSink
comp = Composer(type="qpsk", sps=8, fs=1e6, continuous=True)
with ZmqSink("tcp://0.0.0.0:5555") as sink:
for blk in comp.stream(4096, realtime=True): # paced to fs
sink.send(blk, fs=1e6, fc=0.0)
Under the hood stream() drives a SampleClock, which you can also use
directly when you need the slack value, the timestamp, or a custom loop:
from doppler.wfm.compose import Composer, SampleClock, ZmqSink
comp = Composer(type="qpsk", sps=8, continuous=True)
clk = SampleClock(fs=1e6)
with ZmqSink("tcp://0.0.0.0:5555") as sink:
while True:
blk = comp.execute(4096)
sink.send(blk, fs=1e6, fc=0.0)
clk.pace(len(blk)) # sleep to epoch + n/fs (GIL released)
pace() returns the slack in seconds (negative = it was late). The sleep
happens in C with the GIL released, so a paced producer thread doesn't stall
the rest of the interpreter. Underruns are tallied in clk.underruns /
clk.max_lateness; SampleClock(fs, resync=True) re-anchors on underrun.
Choosing the block size¶
The block period N/fs is your timing resolution. Pick N so the period
comfortably exceeds scheduler jitter (~50 µs–2 ms on stock Linux) — ≥ 1 ms is
a good floor.
fs |
N |
period N/fs |
comment |
|---|---|---|---|
| 1 MS/s | 4096 | 4.1 ms | comfortable |
| 100 kS/s | 1024 | 10 ms | very smooth |
| 50 MS/s | 4096 | 82 µs | below jitter — pace per block, buffer absorbs the rest |
At high fs you can't pace every sample; you pace per block and let the
consumer's buffer absorb the jitter.
Timestamping¶
The same clock answers "when does this block belong?" without a syscall:
clk = SampleClock(fs=1e6)
ts = clk.stamp() # ideal ns timestamp of the next sample
clk.pace(len(blk))
This is a drift-free idealized timeline, distinct from the wall-clock instant
a block happens to be transmitted. Use it for reproducible capture metadata:
SigMF core:datetime, per-record timestamps, aligning a generated capture to a
reference epoch.
The ZMQ wire header is already timestamped
ZmqSink.send stamps each block's wire header with CLOCK_REALTIME at the
moment of send (jittery, but truthful for "when it left"). SampleClock's
stamp is the complementary ideal timeline — pace for flow control,
timestamp for the clean schedule.
The streaming FIFO: the mmap ring buffer¶
Pacing and consumption rarely happen in the same loop. The robust pattern is a producer/consumer split across a FIFO: the producer fills it at the paced real-time rate, the consumer drains it on its own schedule, and the buffer absorbs the jitter on both sides.
doppler's F32Buffer / F64Buffer is built for exactly this — a
lock-free single-producer/single-consumer ring that uses virtual-memory
mirroring (the same physical pages mapped at A and A+N) so a block that
wraps the end is still one contiguous, zero-copy span. No wrap-around branch, no
copy.
import threading
from doppler.buffer import F32Buffer
from doppler.wfm.compose import Composer, SampleClock
ring = F32Buffer(capacity=1 << 20) # 1 Mi-sample ring
def produce(): # paced to real time
comp = Composer(type="qpsk", sps=8, continuous=True)
clk = SampleClock(fs=1e6)
while True:
blk = comp.execute(4096)
while not ring.write(blk): # backpressure if consumer is slow
pass
clk.pace(len(blk))
threading.Thread(target=produce, daemon=True).start()
while True:
block = ring.wait(8192) # zero-copy view across the wrap
process(block)
ring.consume(8192)
The producer is the only thing that knows about real time; the consumer just
keeps up. ring.dropped reports overruns if it doesn't. This mirrors how a real
SDR pipeline works — a hardware clock fills a DMA ring, software drains it — with
SampleClock standing in for the oscillator.
Applications¶
- Live receiver / display under test — feed a spectrum analyzer or demod a signal arriving at its true rate, so buffering, AGC, and sync behave as they would on real hardware.
- SDR playback emulation — replay a recorded or synthesized capture at the
original
fsover ZMQ, standing in for a radio front-end. - Reproducible timestamped captures — stamp segments on an ideal timeline for SigMF metadata that's independent of when the file was written.
- Soak / throughput tests — run
--continuous --realtimefor hours and watchunderruns/droppedto confirm a pipeline sustains real-time rate.
See also¶
- Waveform Generator guide — the
--realtimeflag in context. - Python
composeAPI —SampleClock,Composer,ZmqSink.