Skip to content

wfmgen — User API: Surface, Target & Decisions

Status: design note for the 0.11.0 API cleanup

Captures the agreed target so the consolidation PRs execute against one spec. Lands before the 0.11.0 composition release. Decisions marked [decided] are ratified; [proposed] awaits sign-off.

The model

doppler is C-first: every algorithm lives in C once, and everything else is a thin face. The waveform generator is four pieces over one core:

flowchart TB
    CLIB["<b>C lib</b> · wfm_* (libdoppler)<br/>every algorithm, once"]
    CCLI["<b>C CLI</b> · wfmgen<br/>the only true standalone"]
    PYLIB["<b>Python lib</b> · doppler.wfm<br/>Synth · Segment · Composer · …"]
    PYCLI["<b>Python console script</b> · wfmgen<br/>execs the C CLI"]

    CLIB -->|built into| CCLI
    CLIB -->|bound as glue| PYLIB
    CCLI -->|exposed as| PYCLI

    %% library use ← left column · CLI use ← right column
  • C lib — the algorithms (wfm_synth engine + the wfm_* composer), once.
  • Python lib (doppler.wfm) — just glue over the C lib.
  • C CLI (wfmgen) — the one standalone executable (the composer superset).
  • Python console script (wfmgen) — a tiny os.execv shim over the bundled C binary; no CLI logic in Python.

Naming: the wfm umbrella

One prefix ties the whole subsystem together — wfm (waveform). Each Python name mirrors its C backing at the same level.

Layer Name Was
Python package doppler.wfm (submodules wfm.synth/wfm.compose/wfm.io, re-exported at top) doppler.wfm
C symbol prefix wfm_* — engine wfm_synth, wfm_compose, wfm_resolve, wfm_writer, … mixed synth_* + wfm_*
C CLI + console script wfmgen wfmgen (C) / wavegen (py)
engine C wfm_synth · Python wfm.synth → class Synth synth · Synth
composition / I/O classes Composer · Segment · Timeline · Writer · Reader · ZmqSink (same)

Decisions

# Decision Status Rationale
D0 The wfm umbrella — one prefix across C/CLI/Python (see the table above). All C symbols wfm_*; rename engine synthwfm_synth. [decided] One name ties the subsystem together; each Python name mirrors its C backing at the same level.
D1 One CLI: a C binary named wfmgen (the composer superset). [decided] A 1-segment run is today's single-shot, byte-for-byte — ship the superset; wfmgen keeps the wfm prefix (supersedes the earlier wavegen pick).
D2 The wfmgen console-script is a thin os.execv shim over the bundled C binary. [decided] No CLI logic in Python; one implementation, in C.
D3 Drop the pep723 wavegen.py. [decided] It is a second Python reimplementation; cut for one-CLI purity.
D4 One waveform object: Synth (doppler.wfm.Synth ↔ C wfm_synth). Source is removed. [decided] The namespace carries the wfm identity, so the class stays the meaningful Synth; the object is the generator. Spike-validated (thin Python Synth, lazy C engine): generate + compose byte-identical, and composing spins zero engines (reads the config tuple), so the lazy wrapper is free.
D5 Ship the wfmgen C binary in the wheel (package data) + build it in cibuildwheel. [decided] Only ~820 KB, self-contained (static ZMQ); cost is wheel plumbing, not bytes.
D6 Land the consolidation before 0.11.0. [decided] The feature release ships the clean model + accurate docs.
D7 One import path: re-export everything at doppler.wfm. [decided] Part of the umbrella; today Synth is at the package but Composer/Segment/… are only under .compose.
D8 Rename builder/method params for consistency: noise(nf=)noise(level=); Segment.sum(n=)num_samples=. [decided] level and num_samples are used everywhere else.
D9 Hide Synth's ~12 jm get_*/set_* state accessors from the public surface. [decided] Internal engine state; falls out of the Synth Python-wrapper rework (D4).
D10 Rename the Python package doppler.wfmdoppler.wfm. [decided] The umbrella name; breaking import change (pre-1.0; do it now).

The composition verbs are settled and unchanged: Segment.sum() mixes sources at the same time (one noise floor); Segment.add() sequences segments in time. No +/- operators.

Current surface (baseline)

A · CLI flags (wavegen/wfmgen)

Group Flags
engine --type --fs --freq --snr --snr_mode --seed --sps --pn_length --pn_poly --lfsr --count
compose (wfmgen-only) --from-file --level --off --repeat --continuous
amplitude --headroom --clip-report --clip-error
output --sample_type --file_type --endian --output/-o --record --fc
realtime --realtime --realtime-resync --detached

B · Python doppler.wfm

Symbol Import Role Key surface
Synth(type, fs, freq, snr, snr_mode, seed, sps, pn_length, pn_poly, lfsr) doppler.wfm live single-waveform engine .step() · .steps(n) · .reset() (+ ~12 jm get_*/set_*)
tone() bpsk() qpsk() pn() noise(nf=) .compose builders → Source return a Source
Source(type, freq, snr, snr_mode, seed, sps, pn_length, pn_poly, lfsr, level) .compose untimed waveform spec (no fs/state)
Segment(…, num_samples, off_samples, sources) .compose timed; 1+ sources .sum(*sources, n, off, fs) · .add(*others)
Timeline(segments) .compose segments in time .add(*segments)
Composer(segments\|Timeline\|Segment, *, repeat, continuous) .compose driver → samples .compose() · .execute(n) · .stream() · .to_json() · .from_json() · .from_file()
Writer(path, *, file_type, sample_type, endian, fs, fc, total, headroom) .compose container writer .write() · .track_clipping() · .peak_dbfs · .clip_fraction · .clipped · .close()
ZmqSink(endpoint, *, sample_type) .compose zmq:// publisher .send(iq, fs, fc) · .close()
Reader(path, *, sample_type, endian) .compose container reader .read() · .read_all() · props
read_iq(path, sample_type, endian, *, raw) .readback one-shot file read
SampleClock(fs, *, resync) .compose real-time pacing .pace(n) · .stamp() · .reset() · props
mls_poly · rrc_taps · dsss_spread · sigmf_meta · write_blue_header .compose DSP / format helpers
PN · bpsk_map · qpsk_map · wfm_awgn_amplitude · wfm_ebno_to_snr_db doppler.wfm low-level primitives

Target surface (0.11.0)

Same as the baseline, under the wfm umbrella (D0), with these deltas:

  • One CLI wfmgen (the composer; all flags above, with --from-file etc. no longer "wfmgen-only").
  • One waveform object Synth — config + .steps()/.step()/.reset() + usable directly in Segment.sum(Synth(...), …). Source removed; builders tone()/qpsk()/pn()/bpsk()/noise() return Synth. Synth becomes a thin Python object that lazily spins the C engine (so composing N synths does not allocate N engines), and the raw jm C type + its get_*/set_* go internal (D9).
  • One import path (D7): from doppler.wfm import Synth, Segment, Timeline, Composer, Writer, ZmqSink, Reader, tone, qpsk, ….
  • Consistent params (D8): noise(level=), Segment.sum(num_samples=).

Target waveform usage:

from doppler.wfm import Synth, Segment, Composer, Writer, qpsk, tone

Synth(type="qpsk", snr=12).steps(4096)            # direct: generate now
scene = Segment.sum(                              # compose: same object
    qpsk(snr=15, level=-10),                      #   builders return Synth
    tone(freq=2e5),
    num_samples=65536,                            #   (was n=)
)
Composer(scene).compose()

Breaking changes / migration (pre-1.0, allowed)

Was Now
from doppler.wfm import … / …wfmgen.compose import Source, qpsk, … from doppler.wfm import Synth, qpsk, …
Source(...) / builder → Source Synth(...) / builder → Synth
noise(nf=-20) noise(level=-20)
Segment.sum(*srcs, n=4096) Segment.sum(*srcs, num_samples=4096)
pep723 wavegen.py / Python-implemented wavegen removed; wfmgen CLI + os.execv shim
C synth_* C wfm_synth_*

Execution plan

Riskiest gate first (the jm drift gate), each step its own PR:

  1. The wfm rename (D0/D10) — synth_*wfm_synth_* (C); package doppler.wfmdoppler.wfm; reconcile objects/, just-makeit.toml (module name), and keep jm status --check green.
  2. jm-app removal — delete the single-shot wavegen jm app + the Python CLI (doppler.wfm.cli) + wavegen.py; the C wfmgen composer binary becomes the one CLI.
  3. Wheel-ship + shim (D2/D5) — package the wfmgen binary as data; the wfmgen console-script main() os.execvs it; wire cibuildwheel to build/bundle it across cp39–cp314 × {linux, macos}.
  4. Synth unification (D4/D9) — fold Source into a thin Python Synth; builders return Synth; Segment.sum accepts Synth; hide accessors.
  5. Import unification + param renames (D7/D8).
  6. Docs — rewrite guide/gallery to the four-box model + one CLI; update the two diagrams; flip examples to the unified Synth + single import.
  7. Cut 0.11.0 (the existing release checklist).