{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "wfmgen.schema.json",
  "title": "wfmgen",
  "description": "Canonical spec for the doppler waveform composer. Shared by --from-file (read) and --record (write); a recorded run reproduces byte-for-byte when fed back. The spec describes the SIGNAL, not the container: --file-type, --sample-type and --endian have no section here, so a replay that wants the same file back supplies them again on the command line.",
  "type": "object",
  "required": ["version", "segments"],
  "additionalProperties": false,
  "properties": {
    "version": {
      "const": 1,
      "description": "Schema version. Must be the integer 1."
    },
    "repeat": {
      "type": "boolean",
      "default": false,
      "description": "Loop the whole segment sequence after the last segment finishes."
    },
    "continuous": {
      "type": "boolean",
      "default": false,
      "description": "Never finish; execute always returns max samples. Implies repeat."
    },
    "seed_advance": {
      "type": "string",
      "enum": ["none", "noise", "all"],
      "default": "none",
      "description": "How the seed advances on each repeat of a looped/continuous stream. 'none': byte-identical repeats. 'noise': advance only the AWGN seed, so each loop is a fresh noise realization while the signal (LO/PN code/data/pulse) stays bit-identical. 'all': advance the whole seed, so code, data, and noise all change. The first pass is always the unmodified seed."
    },
    "headroom": {
      "type": "number",
      "default": 0.0,
      "description": "Writer output backoff (dB). Applied at the writer, not the composer, so the segment-level SNR values are not affected. Omitted in the round-trip record when 0 to keep pre-headroom specs byte-identical."
    },
    "segments": {
      "type": "array",
      "minItems": 1,
      "description": "Ordered sequence of segments. Each segment is either a single inline source or a multi-source sum. Segments play in order; repeat/continuous governs what happens after the last one.",
      "items": { "$ref": "#/$defs/segment" }
    }
  },

  "$defs": {

    "number_range": {
      "type": "array",
      "minItems": 2,
      "maxItems": 2,
      "items": { "type": "number" },
      "description": "A [lo, hi] pair drawn uniformly at random, fresh on each segment repeat. The draw is a reproducible hash of (seed, repeat index, segment, source, field) — no RNG state — so a recorded run replays byte-for-byte."
    },
    "count_range": {
      "type": "array",
      "minItems": 2,
      "maxItems": 2,
      "items": { "type": "integer", "minimum": 0 },
      "description": "A [lo, hi] sample-count pair (non-negative integers) drawn uniformly per repeat (see number_range)."
    },
    "level_range": {
      "type": "array",
      "minItems": 2,
      "maxItems": 2,
      "items": { "type": "number", "maximum": 0.0 },
      "description": "A [lo, hi] dBFS level pair (each ≤ 0) drawn uniformly per repeat (see number_range)."
    },

    "source_type": {
      "type": "string",
      "enum": ["tone", "noise", "pn", "bpsk", "qpsk", "chirp", "bits", "symbols", "dsss"],
      "description": "Waveform type. tone: complex sinusoid. noise: AWGN. pn: PN sequence (LFSR). bpsk/qpsk: PN-driven modulation. chirp: linear-FM sweep. bits: user-supplied bit pattern with selectable modulation. symbols: user-supplied complex constellation stream. dsss: spread-spectrum — a two-code burst (repeated preamble + data-code-spread frame) by default, or a continuous asynchronous stream when symbol_rate is set."
    },

    "snr_mode": {
      "type": "string",
      "enum": ["auto", "fs", "ebno", "esno"],
      "default": "auto",
      "description": "SNR reference convention. auto: Es/N0 for bpsk/qpsk/dsss, over full-scale for the other six (tone/noise/pn/chirp/bits/symbols) -- bits included, because a bits frame has no symbol rate the engine can infer; the guide's Levels & SNR page is the one home for that split. fs: dB relative to full scale. ebno: Eb/N0 (per-bit). esno: Es/N0 (per-symbol; for a dsss burst the outer data symbol of len(data_code) chips x sps samples, for a continuous dsss stream the fs/symbol_rate samples the async symbol spans)."
    },

    "generated_sequence": {
      "description": "A run of bits produced from a handful of numbers instead of carried as an array — which is what makes a long capture reproducible from its metadata rather than from a million-symbol run. Recorded alongside the literal key it replaces (acq_code_gen, data_code_gen, sync_gen, payload_gen); a field is one or the other, never both. The masks are hex STRINGS, not numbers: they are uint64 and a JSON number is a double, so a 64-bit register's polynomial would not survive a round trip as a number.",
      "type": "object",
      "required": ["kind", "len"],
      "additionalProperties": false,
      "properties": {
        "kind": {
          "enum": ["pn", "gold", "dotted"],
          "description": "Which generator produces the bits. 'literal' is absent deliberately: a literal field is recorded as its own '0'/'1' string, so naming it here would be a second way to say the same thing."
        },
        "len": {
          "type": "integer",
          "minimum": 1,
          "description": "Output length in bits. Independent of reg_bits: the register width sets the PERIOD (2^reg_bits - 1), this sets how many bits are emitted, and conflating the two is easy and costly."
        },
        "reg_bits": {
          "type": "integer",
          "minimum": 1,
          "maximum": 64,
          "description": "Register width for kind=pn/gold; period is 2^reg_bits - 1."
        },
        "poly": {
          "type": "string",
          "pattern": "^0[xX][0-9a-fA-F]+$",
          "description": "kind=pn: the LFSR tap mask. '0x0' selects the maximal-length polynomial for reg_bits — the same 'zero means derive' rule --pn-poly applies. A literal zero reaching pn_create() would be a register with no feedback."
        },
        "seed": {
          "type": "string",
          "pattern": "^0[xX][0-9a-fA-F]+$",
          "description": "kind=pn: initial register state. '0x0' selects 1; an all-zero register is a fixed point."
        },
        "lfsr": {
          "type": "integer",
          "enum": [0, 1],
          "description": "kind=pn: 0 = Galois, 1 = Fibonacci."
        },
        "taps_a": {
          "type": "string",
          "pattern": "^0[xX][0-9a-fA-F]+$",
          "description": "kind=gold: first register's tap mask."
        },
        "seed_a": {
          "type": "string",
          "pattern": "^0[xX][0-9a-fA-F]+$",
          "description": "kind=gold: first register's initial state ('0x0' selects 1)."
        },
        "taps_b": {
          "type": "string",
          "pattern": "^0[xX][0-9a-fA-F]+$",
          "description": "kind=gold: second register's tap mask."
        },
        "seed_b": {
          "type": "string",
          "pattern": "^0[xX][0-9a-fA-F]+$",
          "description": "kind=gold: second register's initial state ('0x0' selects 1)."
        }
      }
    },

    "source": {
      "description": "One additive source within a segment. Inline single-source segments embed these fields directly alongside fs/num_samples/off_samples. Multi-source segments place them inside the 'sum' array.",
      "type": "object",
      "required": ["type"],
      "additionalProperties": false,
      "properties": {
        "type": { "$ref": "#/$defs/source_type" },

        "freq": {
          "default": 0.0,
          "description": "Carrier frequency offset from baseband (Hz). For chirp: the start frequency. Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "number" },
            { "$ref": "#/$defs/number_range" }
          ]
        },
        "f_end": {
          "default": 0.0,
          "description": "Chirp sweep end frequency (Hz). Ignored by all types other than chirp. Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "number" },
            { "$ref": "#/$defs/number_range" }
          ]
        },

        "snr": {
          "default": 100.0,
          "description": "Signal-to-noise ratio (dB), interpreted according to snr_mode. 100 dB is the clean-signal threshold (no AWGN injected). Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "number" },
            { "$ref": "#/$defs/number_range" }
          ]
        },
        "snr_mode": { "$ref": "#/$defs/snr_mode" },

        "seed": {
          "type": "integer",
          "minimum": 0,
          "default": 0,
          "description": "PRNG seed (AWGN) and/or LFSR initial state (pn/bpsk/qpsk/bits)."
        },
        "sps": {
          "type": "integer",
          "minimum": 0,
          "default": 1,
          "description": "Samples per symbol (bpsk/qpsk/bits) or per chip (pn). 0 in serialised noise sources (field unused; zero-initialised by wfm_resolve_noise)."
        },

        "pn_length": {
          "type": "integer",
          "minimum": 0,
          "default": 15,
          "description": "LFSR register length in bits. 2–64 for PN-bearing types; 0 in serialised noise sources (field unused; zero-initialised by wfm_resolve_noise). Auto-selects the MLS primitive polynomial when pn_poly is 0."
        },
        "pn_poly": {
          "type": "number",
          "minimum": 0,
          "default": 0,
          "description": "LFSR generator polynomial in the Galois bit-vector convention. 0 auto-selects the built-in primitive polynomial for pn_length. Polynomials larger than 2^53 are not losslessly representable as JSON numbers; use 0 (auto) or the CLI for register lengths that require them."
        },
        "lfsr": {
          "type": "string",
          "enum": ["galois", "fibonacci"],
          "default": "galois",
          "description": "LFSR feedback convention. fibonacci taps are derived from the canonical Galois polynomial so both conventions produce the same MLS sequence with the same period."
        },

        "level": {
          "default": 0.0,
          "description": "Source average power in dBFS (≤ 0). 0 = unit average power (no scaling). Used in multi-source sum segments to place each source relative to the shared noise floor. Omitted in the round-trip record when 0 to keep single-source specs byte-identical. Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "number", "maximum": 0.0 },
            { "$ref": "#/$defs/level_range" }
          ]
        },

        "doppler": {
          "default": 0.0,
          "description": "Clock Doppler in ppm. Rescales the whole received time base by 1 + doppler\u00d71e-6, so the symbol and chip rates move with the carrier \u2014 which is what a real pass does and what freq cannot express (an offset moves the carrier alone). Omitted from the round-trip record when 0 and doppler_rate is 0, in which case no channel is built at all. Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "number" },
            { "$ref": "#/$defs/number_range" }
          ]
        },

        "doppler_rate": {
          "default": 0.0,
          "description": "Linear ramp on doppler, in ppm per second of elapsed stream time. The channel runs through a segment's gaps as well as its on-time \u2014 an emitter does not stop moving because its burst ended \u2014 so this is per second, not per second of on-time. Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "number" },
            { "$ref": "#/$defs/number_range" }
          ]
        },

        "carrier_hz": {
          "type": "number",
          "default": 0.0,
          "description": "RF carrier (Hz) the ppm figures are referred to, giving the coherent carrier rotation that accompanies the time-base warp. 0 (the default) warps the clock alone. Independent of doppler/doppler_rate: 0 is a legitimate scene, not an unset field."
        },

        "doppler_lifetime": {
          "type": "string",
          "enum": ["per_instance", "persist"],
          "default": "per_instance",
          "description": "How long this source's Doppler channel lives. per_instance: the channel dies with each repeats instance, so the geometry restarts \u2014 the repeated-trial shape, and what composes with a ranged doppler re-drawn per instance. persist: one continuous pass carries across the segment's gaps and repeat instances, keyed by (segment, source) position. Plan.prepare() REFUSES a persist source, because its cache renders each source independently and concurrently.",
          "$comment": "Omitted from the round-trip record at per_instance."
        },

        "modulation": {
          "type": "string",
          "enum": ["none", "bpsk", "qpsk"],
          "default": "bpsk",
          "description": "Modulation scheme for type=bits. none: the bit pattern is shaped and output as-is (NRZ). bpsk: ±1 BPSK symbols. qpsk: Gray-coded QPSK symbols from pairs of bits."
        },
        "pattern": {
          "type": "string",
          "pattern": "^[01]+$",
          "description": "Bit pattern for type=bits: a string of '0' and '1' characters. Repeats cyclically to fill num_samples. Required for type=bits when used via --from-file; omitted by --record (the pattern is regenerated from seed/pn_length/pn_poly at playback)."
        },

        "symbols": {
          "type": "array",
          "items": { "type": "number" },
          "description": "Complex constellation stream for type=symbols, flat interleaved [re, im, re, im, ...]. Each pair is one output point, oversampled by sps and cycled."
        },

        "acq_code": {
          "type": "string",
          "pattern": "^[01]+$",
          "description": "type=dsss: the acquisition/preamble code as a '0'/'1' string, repeated acq_reps times unmodulated at the head of the burst — the coherent pull-in target the receiver's acquisition locks to."
        },
        "acq_code_gen": { "$ref": "#/$defs/generated_sequence" },
        "acq_reps": {
          "type": "integer",
          "minimum": 0,
          "default": 1,
          "description": "type=dsss: preamble repetitions (periods of acq_code before the frame)."
        },
        "data_code": {
          "type": "string",
          "pattern": "^[01]+$",
          "description": "type=dsss: the payload spreading code as a '0'/'1' string — a second code, distinct from acq_code; every frame bit (sync | payload | crc) is XOR-spread across its full length, so its length is the spreading factor."
        },
        "data_code_gen": { "$ref": "#/$defs/generated_sequence" },
        "sync": {
          "type": "string",
          "pattern": "^[01]+$",
          "description": "type=dsss: the frame-sync word bits (e.g. Barker-13) between the preamble and the payload. Optional."
        },
        "sync_gen": { "$ref": "#/$defs/generated_sequence" },
        "payload_gen": { "$ref": "#/$defs/generated_sequence" },
        "payload": {
          "type": "string",
          "pattern": "^[01]+$",
          "description": "type=dsss: the payload bits of the burst frame ('pattern' is accepted as an alias on input; --record emits 'payload')."
        },
        "rs_depth": {
          "type": "integer",
          "enum": [1, 2, 3, 4, 5, 8],
          "description": "Channel coding: Reed-Solomon (255,223) E=16 over the frame's data group, interleaved this many codewords deep (CCSDS 131.0-B-3 4.3.5.1). The payload plus its CRC must be exactly 223*rs_depth octets — virtual fill is not implemented, so a short frame is refused rather than padded. Absent means no outer code."
        },
        "randomise": {
          "oneOf": [
            { "type": "string", "enum": ["off", "ccsds", "legacy"] },
            { "type": "boolean" }
          ],
          "description": "Channel coding: WHICH section-10 pseudo-random sequence to XOR over the frame's DATA group — not over a marker, preamble or sync word, all of which a receiver has to find and which therefore must look the same in every frame. Its own inverse, so the receive side runs the identical call. \"ccsds\" is 131.0-B-6 10.4.1's 131071-bit generator (h(x)=x^17+x^14+1), which the standard requires; \"legacy\" is 10.4.2's 255-bit one (h(x)=x^8+x^7+x^5+x^3+1), kept for backward compatibility only and carrying spectral lines at 1/255 of the symbol rate. The two are not interchangeable on the air: only the matching receiver derandomises a given waveform. A bare boolean is accepted on input and read as \"ccsds\", so a record written before the choice existed still loads."
        },
        "asm": {
          "type": "boolean",
          "description": "Channel coding: prepend the 0x1ACFFC1D attached sync marker as a field. Not covered by the outer code or the randomiser, and covered by the inner code — that asymmetry is the whole reason a frame is a description of fields and spans rather than a chain of transforms."
        },
        "conv": {
          "type": "boolean",
          "description": "Channel coding: convolutional K=7 rate-1/2 (CCSDS 131.0-B-3 3.3) over the WHOLE frame, marker included. Doubles the bit count. With rs_depth, randomise and asm over a 223*I-octet payload and no preamble or sync word, the result is a CCSDS CADU."
        },
        "interleave": {
          "type": "integer",
          "minimum": 0,
          "description": "Channel coding: block-interleave the data group this many units deep; 0 = none. LAST of the data-group stages, so it is what the channel sees. A burst of up to R consecutive units then touches each codeword at most once, so an outer code correcting t units per codeword survives a burst of t*R."
        },
        "interleave_unit": {
          "type": "integer",
          "minimum": 0,
          "description": "Channel coding: bits per permuted unit; 0 reads as 1. Match it to the outer code's symbol — 8 with rs_depth, because Reed-Solomon is a code over GF(256) and spreading a burst across CODEWORDS means permuting octets. Bit-interleaving an octet code spreads a burst inside a symbol that is already wrong."
        },
        "crc": {
          "type": "string",
          "enum": ["none", "crc16"],
          "default": "crc16",
          "description": "type=dsss (burst): frame trailer — crc16 appends a CRC-16-CCITT over the payload bits (what BurstDemod validates as frame_valid); none omits it. Ignored in continuous mode (no frame)."
        },
        "symbol_rate": {
          "type": "number",
          "exclusiveMinimum": 0,
          "description": "type=dsss: > 0 selects CONTINUOUS asynchronous mode — the spreading code repeats endlessly and data rides on it at this symbol rate (Hz), independent of the code-epoch rate (chips/symbol = fs/sps/symbol_rate, non-integer). Continuous mode has no preamble/sync/CRC frame, so acq_code/acq_reps/sync/crc are omitted; data comes from payload when supplied, else a seeded PN. Absent = burst mode."
        },
        "data": {
          "type": "string",
          "enum": ["none", "prbs"],
          "default": "prbs",
          "description": "Continuous dsss (symbol_rate > 0) data-symbol source: none = code-only (the pure spreading code, no data modulation); prbs = the synth's seeded PN, reproducible receiver-side via doppler.wfm.PN. A payload overrides both. Ignored in burst mode."
        },

        "pulse": {
          "type": "string",
          "enum": ["rect", "rrc"],
          "default": "rect",
          "description": "Pulse shape applied per symbol/chip for pn/bpsk/qpsk/bits/symbols/dsss types. rect: rectangular (no ISI filtering). rrc: root-raised cosine; see rrc_beta and rrc_span. Omitted in the round-trip record when rect to keep default specs compact."
        },
        "rrc_beta": {
          "type": "number",
          "minimum": 0.0,
          "maximum": 1.0,
          "default": 0.35,
          "description": "RRC roll-off factor β (0–1). Only used when pulse=rrc."
        },
        "rrc_span": {
          "type": "integer",
          "minimum": 1,
          "default": 8,
          "description": "RRC filter support in symbols, ONE-SIDED: wfm_rrc_ntaps() produces 2 * rrc_span * sps + 1 taps, unit-energy (sum of h^2 = 1). Only used when pulse=rrc."
        },
        "frame": { "$ref": "#/$defs/frame_desc" }
      }
    },

    "frame_desc": {
      "description": "A frame the CALLER built, and the answer to \"what frame is this?\" when it is present. The flat framing and coding keys beside it (acq_code, sync, crc, rs_depth, randomise, asm, conv, interleave) stay, as SUGAR that builds one of these — so every scene written before this key existed still loads — but a description says things they cannot: a field of the caller's own bits at a position of their choosing, a stage covering a span they name, an arrangement no flag spells. Present, it WINS: wfm_source_describe_frame returns it instead of deriving one, rather than merging the two. KERNELS are not in here and cannot be: a stage names a KIND, and the code that runs it is a wfm_frame_ops_t entry a caller supplies in C.",
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "fields": {
          "type": "array",
          "maxItems": 16,
          "description": "What is on the wire, ordered by POSITION. Ordered independently of stages, because order and coverage are independent axes — in a CCSDS CADU the marker is inserted third and covered by the stage applied fourth, which one ordered list cannot say. Indices into THIS array are what derived_by and a stage's first_field count in.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "name": {
                "type": "string",
                "maxLength": 15,
                "description": "What the field is called; optional, and everything works without it (a field is addressed by index). What it buys is the other direction — a receiver slicing a capture asks for \"payload\" rather than for field 2. Refused rather than truncated when too long, because truncating renames the field."
              },
              "lit": {
                "type": "string",
                "pattern": "^[01]*$",
                "description": "The field's bits, literally, as the same \"0/1\" string every other bit array in this schema uses. Mutually exclusive with \"gen\": a record carrying both is refused, because one field has one source of bits."
              },
              "gen": { "$ref": "#/$defs/generated_sequence" },
              "reps": {
                "type": "integer",
                "minimum": 0,
                "description": "Repetitions of the field's sequence, verbatim; 0 means one."
              },
              "bits": {
                "type": "integer",
                "minimum": 0,
                "description": "DERIVED fields only: length in bits, sized by the stage that produces it."
              },
              "derived_by": {
                "type": "integer",
                "minimum": 0,
                "description": "0 when the caller supplies this field; otherwise the index of the producing stage, PLUS ONE. The +1 is so a zero here is a caller-supplied field rather than silently the output of stage 0."
              }
            }
          }
        },
        "stages": {
          "type": "array",
          "maxItems": 8,
          "description": "The transforms, ordered by APPLICATION, each with the span of fields it covers.",
          "items": {
            "type": "object",
            "required": ["kind"],
            "additionalProperties": false,
            "properties": {
              "kind": {
                "oneOf": [
                  { "type": "string", "enum": ["crc16", "rs", "randomise", "conv", "interleave"] },
                  { "type": "integer", "minimum": 0 }
                ],
                "description": "Which transform. A NAME for a kind doppler names, or the raw INTEGER for a caller's own — allocated from WFM_STAGE_USER (4096) up, which doppler promises never to allocate at or above. Both spellings are accepted on input; the writer emits a name when it has one. A name-only encoding could not carry a caller's kind at all; a number-only one would spell doppler's own stages as magic constants. An unknown NAME is refused rather than defaulted, because kind 0 is crc16 and a defaulting reader would turn every typo into a CRC stage."
              },
              "first_field": {
                "type": "integer",
                "minimum": 0,
                "description": "Index of the first field this stage covers."
              },
              "n_fields": {
                "type": "integer",
                "minimum": 0,
                "description": "Fields covered; 0 means the stage does not run. Load-bearing, not a refinement: a stage that inherited \"whatever ran before me\" is the chain that is right at three stage boundaries and wrong at the fourth — still encoding, still decoding against itself, and syncing to nothing. The cover is what the stage OCCUPIES on the wire, so for a code it is the information AND the check symbols it derives."
              },
              "depth": {
                "type": "integer",
                "minimum": 0,
                "description": "RS / INTERLEAVE: interleaving depth (for INTERLEAVE, the row count; the column count is derived from the span covered)."
              },
              "unit_bits": {
                "type": "integer",
                "minimum": 0,
                "description": "INTERLEAVE: bits per interleaved unit; 0 reads as 1. NOT folded into depth, because the two are independent — depth 8 over octets and depth 8 over bits are different permutations of the same span, and only one of them protects an octet-oriented outer code."
              },
              "emit_num": {
                "type": "integer",
                "minimum": 0,
                "description": "A stage that consumes the assembled frame and emits a DIFFERENT stream sets this: the output is n * emit_num / emit_den bits. 0 means the stage stays inside the frame. An emitting stage covers the WHOLE frame and a description may hold at most one — both properties of wfm_frame_assemble rather than rules invented for their own sake."
              },
              "emit_den": {
                "type": "integer",
                "minimum": 0,
                "description": "Denominator of the emitting stage's rate; see emit_num."
              }
            }
          }
        }
      }
    },

    "inline_segment": {
      "description": "Single-source segment: source fields appear inline alongside the segment timing fields. Byte-identical to driving that source's synth directly.",
      "type": "object",
      "required": ["type"],
      "additionalProperties": false,
      "properties": {
        "type":       { "$ref": "#/$defs/source_type" },
        "fs": {
          "type": "number",
          "exclusiveMinimum": 0.0,
          "default": 1.0,
          "description": "Sample rate (Hz). Omitted means 1.0, which makes every frequency field normalised (cycles per sample) — the same contract wfmgen's --fs flag documents. State it whenever the scene is in real Hz."
        },
        "freq":       { "$ref": "#/$defs/source/properties/freq" },
        "f_end":      { "$ref": "#/$defs/source/properties/f_end" },
        "snr":        { "$ref": "#/$defs/source/properties/snr" },
        "snr_mode":   { "$ref": "#/$defs/snr_mode" },
        "seed":       { "$ref": "#/$defs/source/properties/seed" },
        "sps":        { "$ref": "#/$defs/source/properties/sps" },
        "pn_length":  { "$ref": "#/$defs/source/properties/pn_length" },
        "pn_poly":    { "$ref": "#/$defs/source/properties/pn_poly" },
        "lfsr":       { "$ref": "#/$defs/source/properties/lfsr" },
        "num_samples": {
          "default": 0,
          "description": "On-time length (samples). The synth runs for exactly this many samples before the off-time gap. Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "integer", "minimum": 0 },
            { "$ref": "#/$defs/count_range" }
          ]
        },
        "off_samples": {
          "default": 0,
          "description": "Trailing gap after the segment (samples) — carries the segment's noise floor by default (zeros for clean scenes or gap_noise=off). Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "integer", "minimum": 0 },
            { "$ref": "#/$defs/count_range" }
          ]
        },
        "repeats": {
          "type": "integer",
          "minimum": 1,
          "default": 1,
          "description": "Play the segment this many times back-to-back (each instance = delay + on-time + trailing gap). Ranged fields re-draw and the AWGN is fresh per instance; the signal (codes, payload, PN phase) stays fixed."
        },
        "delay_samples": {
          "default": 0,
          "description": "Leading gap before the on-time (samples) — the burst arrives after this delay. Scalar, or a [lo, hi] pair drawn uniformly per instance (arrival jitter). Carries the segment's noise floor like off_samples.",
          "oneOf": [
            { "type": "integer", "minimum": 0 },
            { "$ref": "#/$defs/count_range" }
          ]
        },
        "gap_noise": {
          "type": "string",
          "enum": ["auto", "off"],
          "default": "auto",
          "description": "Gap policy for the segment's delay and trailing gap. auto (default): gaps carry the segment's noise floor (the sources' AWGN keeps running while the signal stops; clean scenes still get exact-zero gaps). off: gaps are hard zeros."
        },
        "level":      { "$ref": "#/$defs/source/properties/level" },
        "doppler":    { "$ref": "#/$defs/source/properties/doppler" },
        "doppler_rate": { "$ref": "#/$defs/source/properties/doppler_rate" },
        "carrier_hz": { "$ref": "#/$defs/source/properties/carrier_hz" },
        "doppler_lifetime": {
          "$ref": "#/$defs/source/properties/doppler_lifetime"
        },
        "modulation": { "$ref": "#/$defs/source/properties/modulation" },
        "pattern":    { "$ref": "#/$defs/source/properties/pattern" },
        "symbols":    { "$ref": "#/$defs/source/properties/symbols" },
        "acq_code":   { "$ref": "#/$defs/source/properties/acq_code" },
        "acq_code_gen": { "$ref": "#/$defs/generated_sequence" },
        "acq_reps":   { "$ref": "#/$defs/source/properties/acq_reps" },
        "data_code":  { "$ref": "#/$defs/source/properties/data_code" },
        "data_code_gen": { "$ref": "#/$defs/generated_sequence" },
        "sync":       { "$ref": "#/$defs/source/properties/sync" },
        "sync_gen":   { "$ref": "#/$defs/generated_sequence" },
        "payload_gen": { "$ref": "#/$defs/generated_sequence" },
        "payload":    { "$ref": "#/$defs/source/properties/payload" },
        "crc":        { "$ref": "#/$defs/source/properties/crc" },
        "rs_depth":   { "$ref": "#/$defs/source/properties/rs_depth" },
        "randomise":  { "$ref": "#/$defs/source/properties/randomise" },
        "asm":        { "$ref": "#/$defs/source/properties/asm" },
        "conv":       { "$ref": "#/$defs/source/properties/conv" },
        "interleave": { "$ref": "#/$defs/source/properties/interleave" },
        "interleave_unit": { "$ref": "#/$defs/source/properties/interleave_unit" },
        "symbol_rate": { "$ref": "#/$defs/source/properties/symbol_rate" },
        "data":        { "$ref": "#/$defs/source/properties/data" },
        "pulse":      { "$ref": "#/$defs/source/properties/pulse" },
        "rrc_beta":   { "$ref": "#/$defs/source/properties/rrc_beta" },
        "rrc_span":   { "$ref": "#/$defs/source/properties/rrc_span" },
        "frame":      { "$ref": "#/$defs/frame_desc" }
      }
    },

    "sum_segment": {
      "description": "Multi-source segment: two or more sources are summed over the same time span. Noise resolution (wfm_resolve_noise) requires exactly one source to anchor the noise floor — that source carries snr without level; all other sources use level (dBFS) to place their power relative to the floor.",
      "type": "object",
      "required": ["sum"],
      "additionalProperties": false,
      "properties": {
        "fs": {
          "type": "number",
          "exclusiveMinimum": 0.0,
          "default": 1.0,
          "description": "Sample rate (Hz) shared by all sources in this segment. Omitted means 1.0, which makes every frequency field normalised (cycles per sample) — the same contract wfmgen's --fs flag documents."
        },
        "num_samples": {
          "default": 0,
          "description": "On-time length (samples). Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "integer", "minimum": 0 },
            { "$ref": "#/$defs/count_range" }
          ]
        },
        "off_samples": {
          "default": 0,
          "description": "Trailing gap after the segment (samples) — carries the segment's noise floor by default (zeros for clean scenes or gap_noise=off). Scalar, or a [lo, hi] pair drawn uniformly per repeat.",
          "oneOf": [
            { "type": "integer", "minimum": 0 },
            { "$ref": "#/$defs/count_range" }
          ]
        },
        "repeats": { "$ref": "#/$defs/inline_segment/properties/repeats" },
        "delay_samples": { "$ref": "#/$defs/inline_segment/properties/delay_samples" },
        "gap_noise": { "$ref": "#/$defs/inline_segment/properties/gap_noise" },
        "sum": {
          "type": "array",
          "minItems": 1,
          "description": "Sources to add together. Each entry is a source object (no fs/num_samples/off_samples — those are the segment's). The --record output always writes at least two sources here.",
          "items": { "$ref": "#/$defs/source" }
        }
      }
    },

    "segment": {
      "description": "One composer segment. Exactly one of 'type' (inline single-source) or 'sum' (multi-source array) must be present; having both or neither is an error.",
      "oneOf": [
        { "$ref": "#/$defs/inline_segment" },
        { "$ref": "#/$defs/sum_segment" }
      ]
    }

  }
}
