Waveform amplitude & composition¶
Part of wfmgen, which owns the tool's goals, what it composes, and what is still unknown about it.
Status: Implemented
This RFC is now built. Per-source level, --headroom, multi-source
Segment.sum() over one resolved noise floor, and the .add() timeline all
landed on main; the Python API is the unified Synth + tone/bpsk/qpsk/pn/
noise builders with Segment.sum / Segment.add (see the
Waveform Generator guide), and the JSON "sum" schema
behind wfmgen --from-file is byte-identical to it. Single-source,
level = 0, --headroom 0 reproduces the pre-0.11 output byte-for-byte.
--record captures the resolved sum and the --headroom, so
--from-file reproduces a scene exactly. The text below is kept as the
design rationale.
Motivation¶
The generator currently emits one source per segment, sequences segments in
time, and offers --snr as the only level control. Three things push past that:
- Amplitude is a power, not a constant envelope. Today's tone/PSK/PN
waveforms happen to be constant-envelope (
|z| = 1), so they sit exactly at full-scale. That is a property of the current set, not a design invariant — RRC pulse-shaping, QAM, and OFDM all have PAPR > 0 dB, so their peaks exceed full-scale and clip. The level idiom must be average power, and peak headroom must be a first-class control. (See Amplitude & full-scale.) - Real scenes are sums. A signal of interest plus interferers plus a noise floor is an additive mix of sources at different frequencies and levels — which needs a per-source level, something the single-source model lacks.
- Clipping should be observable. When integer output saturates, the run should say so and tell the user exactly how to fix it.
This RFC introduces an average-power level per source, a --headroom
backoff, peak-driven clip detection, and a two-verb composition model.
Amplitude model¶
The anchor is 0 dBFS = unit average power — the existing normalisation.
Every source declares a level in dBFS (≤ 0): its average power relative to
digital full-scale (±1.0 per I/Q axis).
- A lone source defaults to
level = 0→ today's behaviour. - A source's instantaneous power varies with its PAPR;
levelfixes the average. The composite peak depends on how the summed sources align.
The wire mapping is unchanged: cf32/cf64 verbatim (never clip); ci32/
ci16/ci8 scale each axis by 2^(N-1) (2³¹ / 2¹⁵ / 2⁷), round to nearest
and saturate to the type's range — so -1.0 lands exactly on the most
negative code and +1.0 saturates one short of it, which is two's
complement's asymmetry rather than the mapping's.
SNR lives on the source¶
SNR is a per-source parameter (snr, snr_mode), as it already is in
synth. Two reasons:
- It is self-referential.
qpsk(snr=15)means this source at 15 dB — the reference is the source it is attached to, by definition. No external "SNR vs what?" needs answering. snr_modeis intrinsic to the modulation. Es/No and Eb/No convert to a noise level using the source's bits-per-symbol and samples-per-symbol; atoneand aqpskcannot share one Eb/No. The reference frame belongs with the source.
The conversions (unchanged from today):
SNR_fs = snr # snr_mode = fs
SNR_fs = EsNo − 10·log10(sps) # snr_mode = esno
SNR_fs = EbNo + 10·log10(bps) − 10·log10(sps) # snr_mode = ebno
One shared noise floor¶
A segment models one receiver, so it has one thermal noise floor across the band. Source-level SNR resolves against it:
- A source's
snranchors the floor:floor_dBFS = level(src) − SNR_fs(src)(noise power= P_src / 10^(SNR_fs/10)). Putsnron the signal of interest. - Other summed sources give a
level; their SNRs are then derived (level − floor). - A floor not tied to any signal is an explicit noise source:
noise(level=N0)sets the floor atN0dBFS (integrated overfs). snron a second source is sugar for "place mesnrdB above the floor" →level = floor + snr.
If nothing anchors a floor (every source clean, snr ≥ 100), there is no noise —
exactly as today.
Worked example¶
Take the three-source mix used in the Python snippet below — a QPSK SoI plus
two CW interferers — at fs = 1 MHz:
from doppler.wfm import Segment, qpsk, tone
N = 100_000
Segment.sum(
qpsk(sps=8, snr=15, snr_mode="esno"), # anchor; level defaults to 0 dBFS
tone(freq=200e3, level=-12.0), # interferer A
tone(freq=-150e3, level=-20.0), # interferer B
num_samples=N,
)
Resolving the powers, step by step:
- Anchor → floor. The QPSK source anchors. Convert its Es/No to SNR over
fs:SNR_fs = 15 − 10·log10(8) = 15 − 9.03 = 5.97 dB. Withlevel = 0,floor_dBFS = 0 − 5.97 = −5.97 dBFS. The noise floor is placed there, integrated over the full band. - Per-source linear gains (
gain = 10^(level/20),wfm_compose.c): QPSKlevel 0 → ×1.000; interferer A−12 dBFS → ×0.251(power−12 dBcvs the SoI); interferer B−20 dBFS → ×0.100(−20 dBc). - AWGN amplitude (
wfm_awgn_amplitude.c): per-quadratureσ = sqrt(P_sig / (2·10^(SNR_fs/10))). With the SoI's average powerP_sig = 1andSNR_fs = 5.97 dB(10^0.597 = 3.95),σ = sqrt(1 / (2·3.95)) = 0.356, i.e. complex noise power2σ² = 0.253 ≈ −5.97 dBFS— the floor, as designed. - Realised SNRs. SoI:
0 − (−5.97) = 5.97 dBoverfs(15 dB Es/No). Interferer A sits−12 − (−5.97) = −6.0 dBrelative to the floor; B sits−20 − (−5.97) = −14.0 dB.
This is exactly what TestSNRModes and TestNoise in
src/doppler/wfm/tests/test_dsp_correctness.py measure back from the generated
samples (wfm_awgn_amplitude / wfm_ebno_to_snr_db conversions, plus per-mode
realised-SNR and noise-power estimates).
Raw kernel vs composer: who applies the gain¶
A standalone Synth (and _SynthEngine) is the raw, scale-free kernel:
Synth(type="noise").steps(n) always emits unit complex power
(σ² = 0.5 per quadrature) regardless of level, snr, or snr_mode. The
level gain and the multi-source noise-floor placement above are applied by
the Composer (wfm_compose.c post-multiplies each source by
10^(level/20); wfm_resolve.c sets the floor). So amplitude only appears
once samples flow through a Composer/Segment:
>>> import numpy as np, doppler.wfm as w
>>> x = w.Synth(type="noise", level=-20.0, seed=7).steps(100_000)
>>> round(float(np.mean(np.abs(x) ** 2)), 3) # raw kernel ignores level
0.999
>>> c = w.Composer([w.Segment("noise", level=-20.0, num_samples=100_000, seed=7)])
>>> round(float(np.mean(np.abs(c.compose()) ** 2)), 3) # composer applies it
0.01
This split is intentional — the kernel stays scale-free so it composes
cleanly — but it is easy to trip over: set level/snr on a Segment/
Composer, not on a bare Synth, when you want them to take effect.
compose() needs a finite spec¶
Composer.compose() materialises the entire stream into one array, so it
requires a bounded timeline. A repeat=True (loop the sequence) or
continuous=True (never stop) spec has no end — calling compose() on one
loops without bound. Pull bounded blocks instead with Composer.stream(block=…)
or Composer.execute(n) (the same engine behind the CLI --continuous). The
repeat/continuous flags still round-trip through to_json/to_dict.
Headroom¶
--headroom <dB> (default 0) reserves peak room by scaling the final
composite (signal + interferers + noise) so its average power sits at
−H dBFS. It is a single common gain g = 10^(−H/20) applied just before
quantisation.
- SNR-invariant. A common scale changes no power ratio — only the absolute
level versus full-scale. Headroom is orthogonal to every
snrandlevel. - Default
0= today: composite average power at full-scale; constant- envelope clean signals fill it, high-PAPR or noisy peaks clip. - Rule of thumb: set
H ≳ PAPR(composite). Constant-envelope clean →0; RRC/QAM ≈ 4–8 dB; noisy more. - Scope: a run-level
--headroomdefault, overridable per segment (headroom=on a segment). - Recorded into BLUE/SigMF/
--recordmetadata so the absolute level is self-describing.
Clip detection¶
Clipping is made observed and quantified without burdening the hot path.
- Peak-driven. Track the running peak
|I|/|Q|of the cf32 composite — a singlemaxreduction. Peak> 1.0⇒ it clipped (integer types), and the remedy falls straight out:headroom = ⌈20·log10(peak)⌉dB. - Fused, no second pass. The
maxfolds into the loop already touching every sample (the quantise/headroom-scale pass). That loop is store/memory-bound, so avmaxaccumulator hides under the stores — effectively free. - Opt-in fraction. The exact clipped fraction ("12.4 %") is the one
per-sample conditional; gate it behind
--clip-report. The default path is byte-identical to today. --clip-errorturns a clip into a non-zero exit (for pipelines/CI).- Library face. The writer /
Composerresult exposespeak_dbfsand (when requested)clip_fraction, so Python callers assert on them — no stderr in a library.--recordcaptures them too.
wfmgen: warning: peak +6.0 dBFS clipped in ci16.
remedy: add --headroom 6, or use --sample-type cf32.
Float types never clip, but peak_dbfs is still reported so a cf32 capture
documents its true PAPR.
Composition: two verbs, two axes¶
Composition has two orthogonal operations; keeping them distinct keeps the model clear:
| Verb | Axis | Combines | Produces |
|---|---|---|---|
.sum() |
frequency overlay | sources, same time | a Segment |
.add() |
time sequence | segments, back-to-back | a Timeline |
Segment.sum(*synths, num_samples=…)overlays sources at the same time span (the additive mix).segment.add(other)concatenates segments in time (the sequence the composer already is);timeline.add(seg)appends.--off/repeat/continuousstay timeline-level, where they live now.- No
+operator — it would be ambiguous between mix and concatenate, the very distinction this model draws.
Python¶
from doppler.wfm import tone, qpsk, Segment, Composer
# mix: sum sources into one segment (same span)
sig = Segment.sum(
qpsk(freq=0, sps=8, snr=15, snr_mode="esno"), # SoI; snr anchors floor
tone(freq=200e3, level=-12), # CW interferer, 12 dB down
tone(freq=-150e3, level=-20), # another, 20 dB down
num_samples=1_000_000,
)
# concatenate: segments in time — a tone burst, then the mix
scene = Segment.sum(tone(level=0), num_samples=500_000).add(sig)
x = Composer(scene).compose() # materialise the whole timeline
CLI / JSON¶
The composer CLI keeps --from-file SPEC.json; each segment grows a "sum"
array of sources (the JSON face of Segment.sum):
{
"segments": [
{
"n": 1000000,
"sum": [
{ "type": "qpsk", "freq": 0, "sps": 8, "snr": 15, "snr_mode": "esno" },
{ "type": "tone", "freq": 200000, "level": -12 },
{ "type": "tone", "freq": -150000, "level": -20 }
]
}
],
"headroom": 6
}
C¶
wfm_segment_t grows from one synth's parameters to a small list of source
descriptors (each the current synth params plus level). wfm_compose_execute
runs each source's synth_steps into a scratch buffer and accumulates into
the segment output; the noise-anchoring source sets the floor. A single
summation path preserves the byte-identical CLI ⇄ composer guarantee. The
headroom gain and peak/clip tracking live in the writer, after composition.
Compatibility¶
- A single source with
level = 0and--headroom 0is byte-identical to today's single-segment output. --snr/snr_modesemantics are unchanged; they simply now live on a source inside asumrather than on the segment.
Sample timing — the axis this RFC does not have¶
Status: not implemented
Amplitude and composition shipped. This section records the third axis, which they sit beside rather than contain: how many samples a symbol occupies, and whether that number holds still.
Segment.sps is an int. That single type choice is why no scene expresses a
fractional samples-per-symbol or a clock that drifts, and it has three visible
consequences:
- A symbol clock that is 1.004× fast and 1.7 samples late has to be hand-placed in a demo, because no declaration can say it.
- Sources cannot express clock Doppler at all (gh-942).
- The RRC tap count is
2 * span * sps + 1, so the integer is baked into the filter geometry as well as the timeline.
The object already exists¶
MatchedRateConverter — a constructor flavour of RateConverter
(objects/RateConverter.toml), exported from doppler.resample — takes
rate and pulse_sps as doubles. Its terminal stage carries a
pulse-shaped bank instead of the anti-alias one, so the cascade converts the
rate and matched-filters in a single dot product, and that stage's polyphase
arm is the fractional timing delay.
So fractional sps is not a feature to design. It is a constructor argument on
an object already in the tree, already serializable, already benchmarked
(native/benchmarks/bench_RateConverter_core.c). What is missing is a scene
that can name it.
Drift is the control port, driven open-loop¶
execute_ctrl(x, ctrl) and execute_ctrl_push(x, ctrl) steer that fractional
delay per block or per sample, and rate is a writable property, so a slow
ramp is expressible as rate over time rather than as a resampled afterthought.
The receive side already uses this port the other way round: RateSync closes
a timing loop around it, and Symbol Timing on a Rate
Cascade is that page — including the convention that
ctrl is referenced to the terminal rate, which this side must honour
too. The generator drives the same port from a declared clock model instead of
from a loop.
That symmetry is the point, not a convenience: an impairment generated through the same primitive the recovery closes around is provably about the same quantity, rather than two implementations that agree until they do not.
Two cautions, both measured¶
- Zero Doppler is rate 1.0, so every Doppler ramp crosses unity — and
unity is exactly where
resamp's control port once stalled, a rounding cast reaching2^32and landing as zero, so no input was consumed at all. Fixed 2026-08-11. A ramp through unity is the ordinary operating point here, not a corner case, and any test of this axis should cross it rather than approach it. - A rectangle needs room.
pulse="iandd"with fewer than four output samples per symbol degenerates to a two- or three-tap sum; the object raises aUserWarningat construction and exposesnarrow_pulsefor pulling rather than catching. Measured on the timing loop it feeds: lock statistic −0.34 at 2 samples/symbol against +0.95 at 4.
What it changes in the model¶
sps stops being an integer field on a segment and becomes part of the
source's shaping stage — where pulse, beta and span already conceptually
live, and where they are already constructor arguments on the same object.
Four flags describing one object that exists is the same collapse the frame
descriptor performs for framing, on a different axis.
Deliberately not here: the timing loop itself, which is the receive side and has its own page; and the stateful per-source channel gh-942 also asks for, which is a larger question than the clock and should not be smuggled in behind it.
Open questions¶
levelvssnrprecedence when a source over-specifies (both given) — pick one as authoritative or reject.- Coherent peaks.
levelsets per-source average power; the composite peak depends on phase alignment between sources. Clip detection covers the empirical case, but a predicted-headroom hint (from per-source PAPR + count) could help before a run. - Per-source noise. The model assumes one shared floor (one receiver). A multi-emitter / per-channel-noise scenario is explicitly out of scope.
Non-goals¶
- A general DSP graph. This is additive mixing + time sequencing, not arbitrary routing.
- Pulse shaping / QAM / OFDM sources themselves — this RFC is the amplitude and composition substrate they will plug into, not those waveforms.