Scenes — composing in time¶
A scene is segments in sequence: what plays, for how long, what follows,
and what varies per repeat. This page covers the spec format, then the
two things you do with a finished scene — sweep it cheaply with Plan,
or stream it in real time.
What each waveform is belongs to Waveforms; the object model is on the guide index.
\"source\" on this page is \"Synth\" in Python
One waveform's recipe. This page and the scene JSON call it a source;
the Python API calls the same thing a Synth. A segment
is the time span one or more sources are summed over — see
source and segment.
A scene is more than one waveform: sources mixed at the same time, segments
sequenced in time, repeats, and reproducible randomness. This is the
Segment / Timeline / Composer rungs of the ladder in practice.
Both the CLI (--from-file SPEC.json) and the Python Composer drive the same
C engine, so their output is byte-identical for the same parameters.
Composer parameter reference¶
| Flag | Meaning |
|---|---|
--from-file SPEC.json |
run a multi-segment spec |
json-template [FILE] |
subcommand: dump an editable example spec (to FILE, else stdout) |
--level DB |
source level in dBFS (≤0); scales the segment by 10^(DB/20) (SNR-invariant; default 0) |
--headroom DB |
back the output off to −DB dBFS so peaks fit (SNR-invariant; default 0) |
--clip-report |
print the clipped fraction + peak; --clip-error exits non-zero on a clip |
--fc HZ |
capture center frequency, written into BLUE/SigMF metadata |
--off N |
trailing gap after the segment (carries the noise floor; see below) |
--delay N |
leading gap before the segment — arrival delay / jitter |
--gap-noise M |
auto (default: gaps carry the segment's noise floor) / off (hard zeros) |
--repeat |
loop the whole sequence |
--continuous |
never stop (implies repeat) — for streaming |
--seed-advance A |
none (default) / noise / all: how the seed advances per repeat |
--detached |
BLUE only: write <out>.hdr (HCB) + <out>.det (data). Refuses --realtime — see below |
--realtime |
pace the output to fs (see Streaming) |
--realtime-resync |
like --realtime, but re-anchor to "now" on each underrun |
--detached is a file format, not a process model
It selects BLUE's detached header — the HCB in <out>.hdr, the samples
in <out>.det — and nothing about how wfmgen runs. So it is refused,
rather than quietly dropped, alongside anything that cannot honour it:
--realtime/--realtime-resync, a nats:// destination, or a
--file-type other than blue.
Pacing in particular has no consumer to serve: the .hdr carries the
final sample count, so it is written only once the run ends, and a
detached run must be finite anyway. To pace, write a single file
(--file-type blue without --detached) or stream to a broker.
Sequencing segments in time¶
--from-file SPEC.json sequences segments — each a waveform plus an optional
trailing off-time — and can repeat or run forever.
from doppler.wfm import Segment, Timeline, Composer
# The same two segments as the JSON spec above: a tone, then a QPSK burst.
timeline = Timeline([
Segment("tone", fs=1e6, freq=1e5, snr=100.0,
num_samples=10000, off_samples=5000),
Segment("qpsk", fs=1e6, snr=9.0, snr_mode="esno",
sps=8, num_samples=40000),
])
iq = Composer(timeline).compose() # complex64 — byte-identical to the CLI
type and snr_mode are strings in JSON; every other field is numeric and
falls back to the engine default if omitted. A segment's shape is
delay | on | off: delay_samples is a leading gap (arrival delay),
num_samples the on-time, off_samples a trailing gap — use off for
inter-burst spacing and delay for arrival jitter. Gaps are not
silence by default: a noisy segment's AWGN keeps running through its
delay and trailing gap (the channel's noise floor — gh-409), while a clean
scene's gaps stay exact zeros; gap_noise: "off" forces hard zeros.
repeat loops the sequence; continuous never finishes (for streaming).
Rather than write the JSON schema from memory, dump a ready-to-edit example with
wfmgen json-template and edit it down:
wfmgen json-template > scenario.json # or: wfmgen json-template scenario.json
wfmgen --from-file scenario.json -o scenario.cf32
The template is a representative spec — an inline tone, an RRC-shaped
QPSK-from-bits burst with a trailing gap, and a two-source sum mix — that is
valid by construction: it round-trips through --from-file unchanged, so it
doubles as a working starting point, not just documentation.
The schema itself is published alongside these pages:
wfmgen.schema.json (JSON Schema
2020-12). It is the normative description of every key used below — a
template shows one valid spec, the schema says what the whole space of them
is, which is what an editor or a validator needs.
Mixing sources (sum) and sequencing them (add)¶
A segment can hold several sources mixed at the same time — a signal of interest plus interferers plus a noise floor — instead of just one. The two composition verbs are orthogonal:
.sum()mixes sources over the same span (one receiver → one sample rate, one shared noise floor). SNR lives on a source; the floor is resolved once, in C, so the Python, JSON, and CLI faces are byte-identical..add()sequences segments in time, back-to-back — the timeline above, built fluently.
from doppler.wfm import Composer, Segment, qpsk, tone
# A scene: a −12 dB QPSK SoI at +50 kHz over a CW interferer, at 15 dB Es/No.
scene = Segment.sum(
qpsk(snr=15, snr_mode="esno", level=-12), # the anchor sets the floor
tone(freq=5e4), # an interferer (level 0 dBFS)
num_samples=65536,
)
# Sequence a clean preamble tone, then the scene:
timeline = Segment("tone", freq=1e5, num_samples=2000, off_samples=500).add(scene)
iq = Composer(timeline).compose()
Rules of the floor (resolved per segment): an explicit noise(level=N)
source fixes it at N dBFS; otherwise the first source carrying snr is the
anchor and the floor is level(anchor) − SNR_fs(anchor). Other sources place
themselves with level (a plain dBFS offset); giving a non-anchor both snr
and level is a spec error. A single-source segment keeps its bundled AWGN
untouched, so it is byte-identical to the pre-composition path.
In the JSON schema, a mixed segment replaces the inline source fields with a
sum array (each entry is a source; fs/num_samples/off_samples stay on
the segment):
{ "fs": 1e6, "num_samples": 65536, "off_samples": 0,
"sum": [
{ "type": "qpsk", "snr": 15.0, "snr_mode": "esno", "sps": 8, "level": -12.0 },
{ "type": "tone", "freq": 5e4 }
] }
Seed control on repeat¶
A repeated stream should be a stream, not the same bytes over and over. By
default repeats are byte-identical (the seed is fixed). The
--seed-advance knob (spec field seed_advance, honoured by --from-file
and Composer.from_json) chooses how much of the seed advances on each loop:
--seed-advance |
Per repeat | Use it for |
|---|---|---|
none (default) |
byte-identical | exact reproduction / regression |
noise |
only the AWGN seed; signal bit-identical | BER / detection curves over one fixed waveform |
all |
the whole seed → code, data, and noise | a fully stochastic, whole-ensemble stream |
The level is ordered and cumulative — noise keeps the signal, all lets
everything change. For pn/bpsk/qpsk the code and data come from the same
PN LFSR (one seed), so they advance together under all. Under noise, a
fixed preamble or sync code re-acquires every burst while the channel noise
changes — ideal for a soak test, a live receiver feed, or a rotating-file
recorder. The first loop is always the unmodified seed for every mode, so a
finite single-pass run is unaffected and --record stays byte-reproducible.
What creates the loops is --repeat: it plays the whole sequence again
after the last segment, indefinitely. --continuous is the stronger form — it
never finishes and implies --repeat, so every block comes back full. Reach
for --repeat when you want the scene's own segment boundaries preserved in a
looping capture, and --continuous when you are feeding something that just
wants samples forever.
# The scene, on a loop, with fresh noise each time round.
wfmgen --from-file burst.json --repeat --seed-advance noise -o loop.cf32
# A PN preamble + payload, repeating forever, fresh noise each burst:
wfmgen --from-file burst.json --continuous --realtime --seed-advance noise \
-o stream.cf32
Ranged values (lo:hi)¶
The advancing seed re-rolls the noise (and PN data), but the parameters —
frequency, SNR, level, on/off lengths — stay put. To vary a parameter too, give
it a range: a numeric field accepts either a scalar (used as-is) or a
[lo, hi] pair drawn uniformly on each segment repeat.
{ "type": "bpsk", "fs": 1e6, "sps": 8, "pn_length": 7,
"freq": [11200, 12800], // carrier offset re-drawn every burst
"snr": [8, 14], // a fresh SNR each burst
"num_samples": 8192,
"off_samples": [4000, 5600] } // jittered trailing gap → code phase walks
On the CLI the same fields take LO:HI in place of a scalar — --freq 11200:12800, --off 4000:5600, --snr 8:14, --level -12:-3 — and a bare
number is still just that number.
A ranged freq moves the carrier and nothing else. For a source whose
clock is moving — symbol and chip rates scaling with the carrier, as a
real pass does — see Clock Doppler; doppler/doppler_rate
are ranged fields on this same list.
The draw is reproducible without RNG state: each value is a hash of
(seed, repeat index, segment index, source index, field), so --record stores
the range and --from-file replays the identical sequence of draws
byte-for-byte. Ranges compose with the advancing seed and with chirp's
freq/f-end (a sweep whose endpoints jitter per burst).
# Endless bursts, each at a random carrier offset and a jittered gap:
wfmgen --type bpsk --fs 1e6 --sps 8 --pn-length 7 \
--freq 11200:12800 --count 8192 --off 4000:5600 \
--continuous --realtime -o stream.cf32
Burst trains (repeats)¶
A segment can play itself repeats times back-to-back — each instance is
delay | on | off (arrival delay, burst, trailing gap) — before the
timeline advances. That turns
"N bursts, randomly placed with a minimum gap" into one declaration
instead of N copy-pasted segments:
{ "type": "dsss", "fs": 4e6, "sps": 4,
"snr": 10.0, "snr_mode": "esno",
"acq_code": "…", "acq_reps": 5, "data_code": "…",
"payload": "…",
"off_samples": [15000, 40000], // jittered gap, min 15k — per instance
"repeats": 5 } // → a 5-burst train
Instance semantics are exactly what a burst train wants:
- Ranged fields re-draw per instance — with
off_samples: [lo, hi]every gap is a fresh draw (lo= the guaranteed minimum gap), and a rangeddelay_samplesjitters each burst's arrival. The draw key extends the ranged hash with the instance index, so instance 0 renders byte-identically to a repeats-less segment and old scenes are unchanged. - The AWGN is always fresh per instance — two instances never share a
noise realization, regardless of
--seed-advance. - The signal is fixed — codes, payload, PN phase, and every non-ranged
parameter repeat exactly. (To vary the signal too, that is what
--seed-advance allon a looped timeline is for.)
repeats is per-segment instancing; --repeat loops the whole timeline
(advancing the epoch seed). They compose: a two-segment spec with
"repeats": 5 on the first plays 5 bursts, then the second segment, then —
under --repeat — the whole thing again with the next epoch's draws.
On the CLI the single-segment face is --repeats N; in Python it is
Segment(..., repeats=5). See DSSS bursts for the worked
burst-train walkthrough.
Reproducible runs (--record)¶
--record run.json writes the fully-resolved spec — every value after
defaulting (the auto-selected MLS polynomial, the resolved SNR mode, a summed
segment's cleaned anchor + explicit noise floor) and the --headroom. Feed
it straight back with --from-file and you get a byte-identical stream:
wfmgen --type bpsk --count 50000 --sps 4 --headroom 6 --record run.json -o a.iq
wfmgen --from-file run.json -o b.iq # a.iq and b.iq are identical
The recorded --headroom is reapplied on replay; an explicit --headroom on the
--from-file run overrides it. Use --record to document a capture next to its
data, or to pin an exact scenario in a test. The resolved spec also round-trips
through JSON in Python — Composer.from_json(c.to_json()) reproduces the stream.
A frame the caller built¶
The framing keys on a source — sync, acq_code, crc, rs_depth,
randomise, asm, conv, interleave — spell the frames doppler already
knows, at the positions doppler already puts them. A layout outside that
shape has one more key: frame, a description you build yourself.
It is two lists, ordered independently, because position and coverage are
independent axes: fields in wire order, stages in application
order, each stage carrying the span of fields it covers. (In a CCSDS CADU the
marker is inserted third and covered by the stage applied fourth, which a
single ordered list cannot say — see
A CCSDS CADU.)
Present, frame wins: it is the frame, and the flat keys beside it are
not merged in. They keep working exactly as before on every scene that does
not carry one.
{
"version": 1,
"segments": [
{
"type": "bits", "fs": 1e6, "sps": 4, "modulation": "bpsk",
"pattern": "101010101010101010101010",
"snr": 100.0, "snr_mode": "fs", "num_samples": 224,
"frame": {
"fields": [
{ "name": "hdr", "lit": "0101110001011100" },
{ "name": "payload", "lit": "101010101010101010101010" },
{ "name": "crc", "bits": 16, "derived_by": 1 }
],
"stages": [
{ "kind": "crc16", "first_field": 1, "n_fields": 2 }
]
}
}
]
}
That description says the same thing as --sync 0101110001011100 --crc crc16, so the two are byte-identical — which is how you can tell the flat
keys really are sugar for building one of these:
wfmgen --from-file frame.json -o from_desc.cf32
wfmgen --type bits --bits 101010101010101010101010 \
--sync 0101110001011100 --crc crc16 \
--fs 1e6 --sps 4 --snr 100 --count 224 -o from_flags.cf32
python3 - <<'EOF'
import pathlib
import sys
a = pathlib.Path("from_desc.cf32").read_bytes()
b = pathlib.Path("from_flags.cf32").read_bytes()
print("byte-identical" if a == b else "DIFFER")
sys.exit(0 if a == b else 1)
EOF
What the description buys is everything past that: a field of your own bits
at a position you choose, and a stage covering a span you name. hdr above
is neither a preamble nor a sync word, and it sits outside the CRC's cover —
a receiver has to find it before it can check anything.
A derived field must name its producer: derived_by
A field with a declared length and no bits — a CRC trailer, a block of
check symbols — is derived, and in JSON it must say which stage
produces it: derived_by is the stage's index plus one, so 1 means
stage 0. The +1 is what makes a zero mean "the caller supplies this
field" rather than "the output of stage 0".
Omit it and the scene is refused, naming what is missing — the geometry is decided in one place, so the CLI, the C API and Python all say the same thing:
error: this frame description does not lay out: a field that declares a
length but supplies no bits is DERIVED and must name the stage that
fills it (`derived_by` = the stage's index plus one), …
It is worth knowing what that refusal replaced (doppler#1155): the scene
used to load, generate and exit 0 with a different waveform — the
frame came out 40 bits rather than 56, because the unclaimed crc
field was dropped to zero length, and the payload's own bits did not
survive into the record.
The C builder never had the trap: wfm_frame_add_stage() takes the cover
by name and wires the producer itself, so the fact cannot be stated twice
and cannot disagree.
The same description from C and from Python¶
The C struct is the primary interface — wfm_source_t.frame takes a
wfm_frame_desc_t *, and wfm_frame_add_field() / wfm_frame_add_derived()
/ wfm_frame_add_stage() build one by name rather than by index. The
gallery page is a whole program.
Python reaches the same description through the scene JSON rather than
through a keyword, because Composer.from_json() runs the same C code
wfmgen --from-file does:
import json
from doppler.wfm import Composer
seg = {
"type": "bits", "fs": 1e6, "sps": 4, "modulation": "bpsk",
"pattern": "101010101010101010101010",
"snr": 100.0, "snr_mode": "fs", "num_samples": 224,
"frame": {
"fields": [
{"name": "hdr", "lit": "0101110001011100"},
{"name": "payload", "lit": "101010101010101010101010"},
{"name": "crc", "bits": 16, "derived_by": 1},
],
"stages": [{"kind": "crc16", "first_field": 1, "n_fields": 2}],
},
}
x = Composer.from_json(json.dumps({"version": 1, "segments": [seg]})).compose()
print(len(x))
A description also goes back out through to_json(), so a scene read and
re-written keeps its frame rather than silently reverting to a derived one.
Kernels stay in C, by design. A stage names a kind, and the code that
runs it is a wfm_frame_ops_t entry. kind accepts a name for the ones
doppler ships (crc16, rs, randomise, conv, interleave) or a raw
integer for your own, allocated from WFM_STAGE_USER (4096) up — doppler
promises never to allocate at or above that. A kind with no kernel is a
refusal, never a silent skip.
Prepare once, sweep many — Plan¶
Evaluating a system — a detector, a demodulator, a synchroniser — means feeding it the same scene at many operating points: a detection or BER curve is a sweep over SNR; a robustness check nudges a gain or a phase; a Monte-Carlo run repeats one scene under fresh noise. Re-composing from scratch at every point is wasteful, because a composed scene is already a linear form,
and the expensive DSP — spreading, root-raised-cosine pulse shaping, the local oscillator — lives entirely in the signal terms. Those do not change when you sweep a level, a phase, the SNR, or the noise seed. Only cheap coefficients do.
prepare(scene) renders and caches each source once, returning a Plan.
Every subsequent render is a cheap re-weighted sum of the cache — and bit-for-bit
identical to a full compose. It is not a fifth rung on the
object-model ladder; it is a cache over a finished scene, for
when you need that scene many times.
Preparing a scene¶
Build a scene exactly as you would for compose(), then prepare it.
The baseline render() (no overrides) reproduces Composer(scene).compose()
exactly:
import numpy as np
from doppler.wfm import Composer, Segment, prepare, qpsk
scene = Composer(Segment.sum(
qpsk(snr=8.0, seed=7, sps=8, pn_length=9), # the wanted user (anchor)
qpsk(seed=101, sps=8, pn_length=9, level=-6.0), # a co-channel interferer
fs=1e6, num_samples=4096,
))
plan = prepare(scene) # render + cache ONCE
assert np.array_equal(plan.render(), scene.compose()) # baseline is bit-exact
len(plan), plan.n_sources # samples, signal sources
len(plan) is the sample count; plan.n_sources counts the signal sources
(the resolved noise floor is separate). prepare(scene) is shorthand for
Plan(scene) — either works, and a Plan is a context manager if you want to
free its cache promptly.
The overridable axes¶
render() takes five optional overrides. Omit them all for the baseline; pass
any subset to vary that axis. The three per-source axes are lists in scene order,
length n_sources:
| Override | Type | Meaning |
|---|---|---|
gains |
list[float] |
absolute source levels in dBFS (0 = unit power) |
phases |
list[float] |
per-source phase rotation in radians (0 = identity) |
enable |
list[bool] |
False drops a source — an exact gain = 0 term |
snr |
float |
global SNR (dB) — moves only the noise floor |
seed |
int |
the noise realization (defaults to the scene's anchor seed) |
# one wanted user, interferer pulled down 6 dB and rotated 90°, floor at 3 dB
x = plan.render(gains=[0.0, -12.0], phases=[0.0, np.pi / 2], snr=3.0)
x.shape, x.dtype
Every override composes: gains and phases and enable and snr and seed
can all be set in one call. Anything you leave out keeps its resolved value from
the scene.
Which method to reach for¶
render() is the general form. Three convenience methods wrap it for the common
campaigns — reach for whichever names your intent:
at(snr, seed=None)— the scalar fast path (no JSON round-trip), the hot loop of a sweep.seeddefaults to the anchor seed, which reproduces a full compose at that SNR.sweep(snrs, seed=None)— yields(snr, samples)across an SNR list at a held noise seed, so only the floor moves. The natural stimulus for a Pd/BER-vs-SNR curve.monte_carlo(snr, n, seed0=0)— yieldsnindependent noise realizations at a fixed SNR; the signal is identical across draws, only the noise differs.
# a held-seed SNR curve — same noise realization, only the floor moves
curve = {snr: x for snr, x in plan.sweep([-3.0, 0.0, 3.0, 6.0, 9.0])}
# 16 independent noise draws at 6 dB — identical signal, different noise
draws = list(plan.monte_carlo(6.0, 16, seed0=1000))
assert len({d.tobytes() for d in draws}) == 16 # every realization differs
Recipe — a detection / BER curve¶
Sweep the channel SNR and measure a per-point statistic. Here a light matched-filter peak-SNR against a clean copy of the wanted user (itself produced by disabling the interferer — see the next recipe). A real campaign averages each point over Monte-Carlo draws:
# a clean, interference-free copy of the wanted user = the matched filter
template = plan.render(enable=[True, False])[: len(plan) // 2]
template = template / (np.linalg.norm(template) + 1e-30)
def peak_snr(x):
c = np.abs(np.correlate(x, template, mode="valid"))
pk = int(c.argmax())
off = np.delete(c, slice(max(0, pk - 4), pk + 5))
return 10 * np.log10(c[pk] ** 2 / (np.mean(off ** 2) + 1e-30))
snrs = np.arange(-6.0, 13.0, 3.0)
# each point: mean peak-SNR over 8 independent noise draws
detect = [np.mean([peak_snr(plan.at(s, 2000 + j)) for j in range(8)])
for s in snrs]
len(detect) == len(snrs)
The measured curve climbs with channel SNR and then flattens as the multiple-access interference floor takes over — and the cache reproduces the precise noise power the resolver placed at every point.
Recipe — isolate or recombine sources¶
enable drops a source as an exact gain = 0 term, so you can pull any subset
out of a scene without rebuilding it — a clean reference, an interference-only
capture, a jammer-free template:
wanted_only = plan.render(enable=[True, False]) # signal + noise, no MAI
interferer_only = plan.render(enable=[False, True]) # co-channel only + noise
Recipe — a gain-imbalance or phase sweep¶
Because gains and phases are cheap post-multiplies on the cache, a
sensitivity sweep over a relative level or phase is nearly free:
# sweep the interferer's level from 0 down to -18 dB (wanted user fixed at 0)
gain_sweep = [plan.render(gains=[0.0, g]) for g in range(0, -19, -3)]
# sweep its carrier phase across a full turn
phase_sweep = [plan.render(phases=[0.0, ph])
for ph in np.linspace(0, 2 * np.pi, 8, endpoint=False)]
len(gain_sweep), len(phase_sweep)
Why it is fast (and exact)¶
The cost of prepare() is one full render of every source. After that, each
render()/at() is a handful of scaled vector adds over the cache — no LFSR, no
convolution, no transcendentals. So a campaign of P points costs roughly
"one compose + P cheap sums" instead of "P full composes", and the speedup
grows with the number of sources and the sample count, since that is exactly the
signal work the cache elides.
Exactness is guaranteed by construction: the composer's accumulate is
Σₖ gainₖ·synthₖ in source order with gainₖ = 10^(levelₖ/20), and synthₖ
depends on everything except level — so a re-weighted sum of the cached renders
is bitwise identical to a full compose at those gains. render() with no
overrides equals compose() to the last bit, which is the standing test
contract (checked in both harnesses). Phase is a defined render-time rotation
(φ = 0 is the identity, skipped entirely), exact by construction rather than
reproduced.
Scope and limits¶
prepare() needs a scene whose length is fixed and whose per-source
weights are fixed, because that is what makes a re-render a re-weighted
sum instead of a re-synthesis. Two things break that, and they are the two it
refuses:
| refused | why |
|---|---|
a ranged signal field — snr=(4, 8), freq=(0.01, 0.05) |
the value the cache was rendered at is the one thing a sweep is supposed to vary afterwards, so a per-repeat redraw of it has nothing to re-weight |
continuous=True |
the length is open-ended, and the cache is the rendered samples |
Everything else the scene can say is fine — including three things this page
used to claim were rejected. Multi-segment scenes, repeats, and ranged
timing (off_samples, delay_samples, the per-instance jitter a burst
train wants) all prepare, which is what
DSSS bursts relies on. So
does a lone bundled noisy source, whose private RNG is fused into the
signal.
>>> from doppler.wfm import Composer, Segment, prepare
>>> plan = prepare(Composer([Segment(type="qpsk", snr=6.0, num_samples=512,
... off_samples=(100, 300), repeats=2)]))
>>> len(plan) > 0 # ranged TIMING + repeats: prepared
True
>>> try: # a ranged SIGNAL field: refused
... prepare(Composer([Segment(type="qpsk", snr=(4.0, 8.0),
... num_samples=512)]))
... except ValueError:
... print("rejected")
rejected
A prepared Plan can be serialized — plan.save() → bytes,
plan.dump(path) → a file, restored with PlanFromBlob / PlanFromFile — and
the restored cache reproduces the stimulus bit-for-bit. But reach for it only to
checkpoint or resume a live Plan, not to move one between processes: the
blob is the whole rendered cache — roughly n_sources × len(plan) × 8 bytes —
so it grows with the scene, and for anything large, restoring a multi-gigabyte
blob is slower than re-rendering it from scratch.
For a hand-off, persist the scene's compact spec JSON (Composer.to_json())
and re-prepare() on the far side — the re-render is almost always cheaper than
shipping and deserializing the cache, and the spec is kilobytes, not gigabytes.
The rule of thumb: transport the recipe, not the rendered signal.
Frequency (Doppler) and delay (multipath) are planned follow-ups on the same frame — additive axes, not a rewrite.
Streaming — real-time pacing¶
By default wfmgen emits as fast as the CPU allows — fs is only metadata (the
BLUE xdelta, the NATS header). Add --realtime to throttle the output to
fs, so blocks leave on an epoch + n/fs schedule — mimicking a hardware
sample clock feeding the sink. This is what you want when a downstream consumer
expects samples to arrive at the real rate (a live spectrum display, an SDR
playback emulation):
# Stream QPSK to a live receiver at the true 1 MS/s, not as fast as possible.
# Requires a nats-server reachable at the endpoint.
wfmgen --type qpsk --fs 1e6 --sps 8 --continuous --realtime \
--output nats://127.0.0.1:4222/iq
The schedule is drift-free: each deadline is recomputed from the cumulative
sample count against a fixed epoch, so sleep jitter never accumulates — the
long-run rate is exactly fs. Pacing does not alter the samples; a file
written with and without --realtime is byte-identical.
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 instead to re-anchor the clock to "now" on each underrun,
staying near real time going forward at the cost of an inserted gap.
# Same stream, but never try to "catch up" after a stall: a live receiver
# would rather see a gap than a burst of stale samples arriving too fast.
wfmgen --type qpsk --fs 1e6 --sps 8 --continuous --realtime --realtime-resync \
--output nats://127.0.0.1:4222/iq
Which one you want is a question about the consumer, not about the generator.
Keep the default when the absolute timeline is the truth (you are going to
line the capture up against something else afterwards); use --realtime-resync
when staying current matters more than the total elapsed count.
Software pacing is average-rate, not sample-accurate
On a non-realtime OS you get a drift-free average rate with bounded
per-block jitter, never true sample-clock fidelity. Keep blocks large enough
that the period N/fs comfortably exceeds scheduler jitter, and let the
consumer's buffer absorb the rest.
The same clock in Python — SampleClock¶
The same C core is exposed as SampleClock, which paces and timestamps a stream
against an ideal fs-Hz clock — throttle a producer to real time and tag blocks
with their ideal timestamp:
from doppler.wfm import Composer, SampleClock, StreamSink
# Requires a nats-server reachable at the endpoint.
comp = Composer(type="qpsk", sps=8, continuous=True)
clk = SampleClock(fs=1e6)
with StreamSink("nats://127.0.0.1:4222/iq") as sink:
while True:
blk = comp.execute(4096)
ts = clk.stamp() # ideal ns timestamp of this block
sink.send(blk, fs=1e6, fc=0.0)
clk.pace(len(blk)) # sleep to epoch + n/fs (GIL released)
The schedule is drift-free (deadlines come from the cumulative sample count, not
summed sleeps); underruns are counted in clk.underruns / clk.max_lateness,
and SampleClock(fs, resync=True) re-anchors to "now" on each underrun.
SampleClock and StreamSink are POSIX-only. See the
Python API for the full class surface.
See also¶
- Waveforms — what goes into a segment.
- Python API — the same scenes from Python.
- Gallery: scene composition.