Waveform amplitude & composition¶
Status: Implemented — ships in 0.11.0
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 saturate each axis to ±1.0 then scale to ±(2³¹−1 / 32767 / 127).
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.
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, gap, noise, 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 the floor
tone(freq=200e3, level=-12), # CW interferer, 12 dB down
tone(freq=-150e3, level=-20), # another, 20 dB down
n=1_000_000,
)
# concatenate: segments in time
scene = (
Segment.sum(tone(level=0), n=500_000) # a tone burst
.add(gap(n=100_000)) # then silence
.add(sig) # then the mix
)
Composer(scene, headroom=6).write("scene.cf32") # 6 dB backoff for the sum's PAPR
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.
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.