Skip to main content

orion_sdr/modulate/
ofdm.rs

1// Copyright (c) 2025-2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// src/modulate/ofdm.rs
5use super::bpsk::BpskMapper;
6use super::qam::{Qam16Mapper, Qam64Mapper, Qam256Mapper, QamMapper};
7use super::qpsk::QpskMapper;
8use crate::core::{Block, WorkReport};
9use crate::dsp::Rotator;
10use crate::fec::{
11    CrcKind, DecodeRule, HeaderFormat, InnerFec, InterleaverKind, OuterFec, ScramblerKind,
12    ScramblerPos, SeedMode,
13};
14use crate::multicarrier::{
15    CarrierGrid, CarrierPlan, CyclicPrefixInsert, GridMap, IfftBlock, TxLowpass,
16};
17use num_complex::Complex32 as C32;
18
19/// Constellation order used by an OFDM data carrier's symbol mapper.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ConstellationOrder {
22    Bpsk,
23    Qpsk,
24    Qam16,
25    Qam64,
26    Qam256,
27}
28
29impl ConstellationOrder {
30    pub fn bits_per_symbol(self) -> usize {
31        match self {
32            ConstellationOrder::Bpsk => 1,
33            ConstellationOrder::Qpsk => 2,
34            ConstellationOrder::Qam16 => 4,
35            ConstellationOrder::Qam64 => 6,
36            ConstellationOrder::Qam256 => 8,
37        }
38    }
39}
40
41/// OFDM waveform configuration: the resource grid ([`CarrierPlan`]) plus the
42/// sample rate, RF/IF carrier, output gain, and data-carrier constellation
43/// order shared by the transmitter ([`OfdmMod`]) and receiver.
44///
45/// Numerology (`n_fft`, `cp_len`, carrier layout) is caller-owned and lives
46/// in `carrier_plan`; the library bakes in no standard's spacing or CP length
47/// (see the numerology guidance in `docs/design.md`). `rf_hz == 0.0` selects
48/// baseband output; any nonzero value upconverts via a `Rotator`.
49///
50/// The frame-layer fields (`outer_fec`, `inner_fec`, the two interleavers,
51/// `header_format`, the two CRCs, `scrambler`, `scrambler_pos`) default to
52/// "absent" and are set with the `with_*` builder methods, so the positional
53/// [`OfdmConfig::new`] and the bare `OfdmMod`/`OfdmDemod` symbol pipeline are
54/// unaffected by them. They configure the concatenated COFDM coding chain used
55/// by the OFDM frame modulator/demodulator (see `modulate::ofdm_frame`).
56#[derive(Debug, Clone, PartialEq)]
57pub struct OfdmConfig {
58    pub carrier_plan: CarrierPlan,
59    pub fs: f32,
60    pub rf_hz: f32,
61    pub gain: f32,
62    pub constellation: ConstellationOrder,
63    // ── Frame-layer (COFDM) configuration ──
64    pub outer_fec: OuterFec,
65    pub inner_fec: InnerFec,
66    pub outer_interleaver: InterleaverKind,
67    pub inner_interleaver: InterleaverKind,
68    pub header_format: HeaderFormat,
69    pub payload_crc: CrcKind,
70    pub header_crc: CrcKind,
71    pub scrambler: ScramblerKind,
72    pub scrambler_pos: ScramblerPos,
73    /// Check-node rule the receiver's LDPC inner decoder uses.
74    /// [`DecodeRule::SumProduct`] (the default) is exact belief propagation;
75    /// [`DecodeRule::ScaledMinSum`] trades ≲0.3 dB of coding gain for ~2×
76    /// decode throughput (see the R8a investigation in `docs/performance.md`).
77    /// TX-only paths ignore this.
78    pub ldpc_decode_rule: DecodeRule,
79    /// When set, the frame layer maps/demaps payload symbols through DVB-T's
80    /// four-phase **scattered-pilot** grid rotation instead of the single static
81    /// grid in `carrier_plan` (see `waveform::dvb_t`). The `carrier_plan` still
82    /// describes the representative phase-0 grid (its 1512 data carriers drive
83    /// all the count-based bookkeeping); the physical pilot/data bins rotate per
84    /// symbol underneath. Only valid for a 2K DVB-T plan. Defaults to `false`,
85    /// so every non-DVB-T link is unaffected.
86    pub dvb_t_scattered: bool,
87    /// Receiver FFT-window back-off in samples: how far the demodulator pulls
88    /// its `n_fft`-sample window *earlier* from the cyclic-prefix boundary into
89    /// the guard interval (clamped to `cp_len` at use). `0` (the default) is the
90    /// standard CP-boundary window. A positive value leaves guard on both sides
91    /// of the useful part — receiver practice for multipath/pre-echo robustness,
92    /// and the enabler for RX-transparent TX symbol windowing. **RX-only:**
93    /// TX paths ignore this, so on-air output is unaffected.
94    ///
95    /// **Requires an equalizer.** Sliding the window by `b` multiplies every
96    /// subcarrier by a linear phase ramp `exp(-j2πkb/n_fft)` (FFT shift
97    /// theorem). This is transparent only on the *equalized* path (the streaming
98    /// demod, or the DVB-T scattered path), where the training/pilot estimate is
99    /// measured at the same back-off and divides the ramp back out. On a bare,
100    /// unequalized demod (`OfdmDemod` / batch `OfdmFrameDemod` with no channel
101    /// estimate) a nonzero back-off leaves the ramp uncorrected and corrupts the
102    /// decode — leave it `0` there.
103    pub rx_window_backoff: usize,
104    /// Optional TX baseband low-pass (spectral mask) applied by the frame
105    /// modulator across the **assembled** stream, after CP insertion and any
106    /// symbol windowing. `None` (the default) leaves the on-air output
107    /// unchanged.
108    ///
109    /// **TX-only field**, but not RX-indifferent: the filter is a linear
110    /// channel the pilot/training equalizer absorbs, so no *decoding* change is
111    /// needed, yet its group delay must land in guard the receiver discards.
112    /// Pair it with [`rx_window_backoff`](Self::rx_window_backoff) — the same
113    /// knob symbol windowing uses — and keep
114    /// `roll_off + group_delay ≤ min(cp_len − backoff, backoff)`
115    /// ([`TxLowpass::fits_guard`]).
116    pub tx_lowpass: Option<TxLowpass>,
117}
118
119/// Rejects an [`OfdmConfig`] whose frame-layer settings are mutually
120/// inconsistent.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
122pub enum FrameConfigError {
123    /// A per-frame-random scrambler seed has no way to reach the receiver when
124    /// there is no in-band header to carry it.
125    #[error("per-frame-random scrambler seed requires a header (header_format != NoHeader)")]
126    PerFrameSeedNeedsHeader,
127    /// A block interleaver was requested with a zero dimension.
128    #[error("block interleaver dimensions must be nonzero")]
129    ZeroInterleaverDim,
130    /// A BCH outer code was requested with t = 0 (no correction).
131    #[error("BCH outer code requires t >= 1")]
132    ZeroBchT,
133    /// A Reed–Solomon outer code has invalid dimensions.
134    #[error("Reed–Solomon requires 0 < n_parity < n <= 255 with n_parity even")]
135    BadRsConfig,
136}
137
138impl OfdmConfig {
139    pub fn new(
140        carrier_plan: CarrierPlan,
141        fs: f32,
142        rf_hz: f32,
143        gain: f32,
144        constellation: ConstellationOrder,
145    ) -> Self {
146        Self {
147            carrier_plan,
148            fs,
149            rf_hz,
150            gain,
151            constellation,
152            outer_fec: OuterFec::None,
153            inner_fec: InnerFec::None,
154            outer_interleaver: InterleaverKind::None,
155            inner_interleaver: InterleaverKind::None,
156            header_format: HeaderFormat::OrionSdr,
157            payload_crc: CrcKind::Crc32,
158            header_crc: CrcKind::Crc16,
159            scrambler: ScramblerKind::None,
160            scrambler_pos: ScramblerPos::BeforeOuterFec,
161            ldpc_decode_rule: DecodeRule::SumProduct,
162            dvb_t_scattered: false,
163            rx_window_backoff: 0,
164            tx_lowpass: None,
165        }
166    }
167
168    /// Sets the sample rate (S/s). A generic builder — e.g. a DVB-T caller
169    /// selects a narrowband bandwidth mode with
170    /// `cfg.with_fs(NbBandwidth::Bw1MHz.fs())`.
171    pub fn with_fs(mut self, fs: f32) -> Self {
172        self.fs = fs;
173        self
174    }
175
176    pub fn with_outer_fec(mut self, outer_fec: OuterFec) -> Self {
177        self.outer_fec = outer_fec;
178        self
179    }
180
181    pub fn with_inner_fec(mut self, inner_fec: InnerFec) -> Self {
182        self.inner_fec = inner_fec;
183        self
184    }
185
186    pub fn with_outer_interleaver(mut self, il: InterleaverKind) -> Self {
187        self.outer_interleaver = il;
188        self
189    }
190
191    pub fn with_inner_interleaver(mut self, il: InterleaverKind) -> Self {
192        self.inner_interleaver = il;
193        self
194    }
195
196    pub fn with_header_format(mut self, header_format: HeaderFormat) -> Self {
197        self.header_format = header_format;
198        self
199    }
200
201    pub fn with_payload_crc(mut self, crc: CrcKind) -> Self {
202        self.payload_crc = crc;
203        self
204    }
205
206    pub fn with_header_crc(mut self, crc: CrcKind) -> Self {
207        self.header_crc = crc;
208        self
209    }
210
211    pub fn with_scrambler(mut self, scrambler: ScramblerKind) -> Self {
212        self.scrambler = scrambler;
213        self
214    }
215
216    pub fn with_scrambler_pos(mut self, pos: ScramblerPos) -> Self {
217        self.scrambler_pos = pos;
218        self
219    }
220
221    /// Selects the LDPC inner-decoder check-node rule (receiver side). Defaults
222    /// to [`DecodeRule::SumProduct`]; pass [`DecodeRule::ScaledMinSum`] (α ≈
223    /// 0.75) for ~2× decode throughput at a ≲0.3 dB coding-gain cost.
224    pub fn with_ldpc_decode_rule(mut self, rule: DecodeRule) -> Self {
225        self.ldpc_decode_rule = rule;
226        self
227    }
228
229    /// Enables DVB-T four-phase scattered-pilot grid rotation in the frame layer
230    /// (see [`dvb_t_scattered`](Self::dvb_t_scattered)). The `carrier_plan` must
231    /// be a 2K DVB-T phase-0 plan (1512 data carriers).
232    pub fn with_dvb_t_scattered(mut self, scattered: bool) -> Self {
233        self.dvb_t_scattered = scattered;
234        self
235    }
236
237    /// Sets the receiver FFT-window back-off in samples (see
238    /// [`rx_window_backoff`](Self::rx_window_backoff)). RX-only; TX output is
239    /// unaffected. Clamped to `cp_len` where the window is selected.
240    pub fn with_rx_window_backoff(mut self, backoff: usize) -> Self {
241        self.rx_window_backoff = backoff;
242        self
243    }
244
245    /// Enables TX symbol windowing with a `roll_off`-sample raised-cosine taper
246    /// per symbol edge (see
247    /// [`CarrierPlan::with_window_roll_off`](crate::multicarrier::CarrierPlan::with_window_roll_off)).
248    /// `0` disables it (the default). The taper reduces out-of-band emission but
249    /// is only RX-transparent when paired with a compatible
250    /// [`rx_window_backoff`](Self::rx_window_backoff) (`roll_off ≤ cp_len/2` with
251    /// back-off `cp_len/2` is the transparent operating point).
252    ///
253    /// See [`with_symbol_window_beta_guard`](Self::with_symbol_window_beta_guard)
254    /// and [`with_symbol_window_beta_tu`](Self::with_symbol_window_beta_tu) to
255    /// specify the roll-off as a fraction instead of raw samples.
256    pub fn with_symbol_window(mut self, roll_off: usize) -> Self {
257        self.carrier_plan = self.carrier_plan.with_window_roll_off(roll_off);
258        self
259    }
260
261    /// Enables TX symbol windowing with a roll-off given as a fraction of the
262    /// **guard** (cyclic prefix): `roll_off = round(beta * cp_len)`. `beta` in
263    /// `0.0..=0.5`; `beta = 0.5` is the maximum RX-transparent taper
264    /// (`roll_off = cp_len/2`, paired with `rx_window_backoff = cp_len/2`). This
265    /// convention makes the transparency budget explicit, since the taper is
266    /// bounded by half the guard. Clamped to `[0, 0.5]`.
267    pub fn with_symbol_window_beta_guard(self, beta: f32) -> Self {
268        let cp_len = self.carrier_plan.cp_len();
269        let roll_off = (beta.clamp(0.0, 0.5) * cp_len as f32).round() as usize;
270        self.with_symbol_window(roll_off)
271    }
272
273    /// Enables TX symbol windowing with a roll-off given as a fraction of the
274    /// **useful symbol** `Tu` (`n_fft`): `roll_off = round(beta * n_fft)` — the
275    /// convention used by DVB-family windowing tables (which express roll-offs
276    /// relative to `Tu`). Note the resulting `roll_off` must still satisfy the
277    /// transparency bound `roll_off ≤ cp_len/2` for a matched back-off to keep
278    /// the decode transparent; a larger `beta` shapes the spectrum more but is
279    /// only transparent if the guard is long enough. Clamped so `2*roll_off` does
280    /// not exceed the symbol length.
281    pub fn with_symbol_window_beta_tu(self, beta: f32) -> Self {
282        let n_fft = self.carrier_plan.n_fft();
283        let roll_off = (beta.max(0.0) * n_fft as f32).round() as usize;
284        self.with_symbol_window(roll_off)
285    }
286
287    /// Enables the TX baseband low-pass (spectral mask) applied by
288    /// [`OfdmFrameMod::modulate_frame`](crate::modulate::OfdmFrameMod::modulate_frame)
289    /// across the assembled stream (see [`tx_lowpass`](Self::tx_lowpass)).
290    /// Off by default.
291    ///
292    /// Unlike symbol windowing this is *not* bounded by the windowing ceiling —
293    /// it attenuates the skirt directly in the frequency domain, so its gain
294    /// stacks on top. It changes nothing about how the receiver *decodes*, but
295    /// its group delay shares the guard budget with any symbol taper and needs
296    /// a nonzero [`rx_window_backoff`](Self::rx_window_backoff) to land in:
297    /// check [`TxLowpass::fits_guard`].
298    pub fn with_tx_lowpass(mut self, lowpass: TxLowpass) -> Self {
299        self.tx_lowpass = Some(lowpass);
300        self
301    }
302
303    /// Convenience form of [`with_tx_lowpass`](Self::with_tx_lowpass) that reads
304    /// the occupied band edge straight off the carrier plan and centres the
305    /// filter's transition in the unoccupied band above it
306    /// ([`TxLowpass::for_null_band`]). `num_taps` stays the caller's choice
307    /// because it is what the cyclic-prefix budget constrains;
308    /// [`TxLowpass::taps_for_null_band`] suggests a length.
309    pub fn with_tx_lowpass_null_band(self, num_taps: usize, stopband_db: f32) -> Self {
310        let lowpass = TxLowpass::for_null_band(
311            self.carrier_plan.n_fft(),
312            self.carrier_plan.occupied_half_carriers(),
313            num_taps,
314            stopband_db,
315        );
316        self.with_tx_lowpass(lowpass)
317    }
318
319    /// Validates the frame-layer configuration. Returns `Ok(())` for the bare
320    /// (no-FEC, no-frame) defaults.
321    pub fn validate(&self) -> Result<(), FrameConfigError> {
322        // A per-frame-random seed needs a header block to carry it to the RX.
323        // Only OrionSdr has one; NoHeader and DvbTps do not (DvbTps signals via
324        // TPS, which does not carry a scrambler seed).
325        if let ScramblerKind::Additive {
326            seed: SeedMode::PerFrameRandom,
327            ..
328        } = self.scrambler
329            && !self.header_format.has_header_block()
330        {
331            return Err(FrameConfigError::PerFrameSeedNeedsHeader);
332        }
333        for il in [self.outer_interleaver, self.inner_interleaver] {
334            match il {
335                InterleaverKind::Block { rows, cols } if rows == 0 || cols == 0 => {
336                    return Err(FrameConfigError::ZeroInterleaverDim);
337                }
338                InterleaverKind::Convolutional { branches, depth }
339                    if branches == 0 || depth == 0 =>
340                {
341                    return Err(FrameConfigError::ZeroInterleaverDim);
342                }
343                _ => {}
344            }
345        }
346        if let OuterFec::Bch { t } = self.outer_fec
347            && t == 0
348        {
349            return Err(FrameConfigError::ZeroBchT);
350        }
351        if let OuterFec::ReedSolomon { n, n_parity } = self.outer_fec
352            && (n == 0 || n > 255 || n_parity == 0 || n_parity >= n || n_parity % 2 != 0)
353        {
354            return Err(FrameConfigError::BadRsConfig);
355        }
356        Ok(())
357    }
358
359    pub fn bits_per_ofdm_symbol(&self) -> usize {
360        self.carrier_plan.data_carriers().len() * self.constellation.bits_per_symbol()
361    }
362
363    pub fn samples_per_ofdm_symbol(&self) -> usize {
364        self.carrier_plan.n_fft() + self.carrier_plan.cp_len()
365    }
366}
367
368/// Dispatches to the existing per-order symbol mappers (reused verbatim, not
369/// reimplemented) via a plain `match` — no `dyn` dispatch in the hot loop.
370///
371/// `pub(crate)` so `demodulate::ofdm` can reuse it to compute EVM (mapping
372/// hard-decided bits back to their ideal constellation points) without
373/// duplicating the per-order dispatch.
374pub(crate) enum MapperKind {
375    Bpsk(BpskMapper),
376    Qpsk(QpskMapper),
377    Qam16(Qam16Mapper),
378    Qam64(Qam64Mapper),
379    Qam256(Qam256Mapper),
380}
381
382impl MapperKind {
383    fn new(order: ConstellationOrder) -> Self {
384        match order {
385            ConstellationOrder::Bpsk => MapperKind::Bpsk(BpskMapper::new()),
386            ConstellationOrder::Qpsk => MapperKind::Qpsk(QpskMapper::new()),
387            ConstellationOrder::Qam16 => MapperKind::Qam16(QamMapper::new()),
388            ConstellationOrder::Qam64 => MapperKind::Qam64(QamMapper::new()),
389            ConstellationOrder::Qam256 => MapperKind::Qam256(QamMapper::new()),
390        }
391    }
392
393    #[inline(always)]
394    pub(crate) fn process(&mut self, input: &[u8], output: &mut [C32]) -> WorkReport {
395        match self {
396            MapperKind::Bpsk(m) => m.process(input, output),
397            MapperKind::Qpsk(m) => m.process(input, output),
398            MapperKind::Qam16(m) => m.process(input, output),
399            MapperKind::Qam64(m) => m.process(input, output),
400            MapperKind::Qam256(m) => m.process(input, output),
401        }
402    }
403}
404
405/// Constructs the ideal-symbol mapper for `order`, for crate-internal reuse
406/// (e.g. EVM computation in `demodulate::ofdm`).
407pub(crate) fn ideal_symbol_mapper(order: ConstellationOrder) -> MapperKind {
408    MapperKind::new(order)
409}
410
411/// OFDM transmitter: `u8` bits → `C32` IQ.
412///
413/// Pipeline: bits → symbol mapper (BPSK/QPSK/QAM, order given by
414/// `OfdmConfig::constellation`) → [`GridMap`] → [`IfftBlock`] →
415/// [`CyclicPrefixInsert`] → optional [`Rotator`] (`rf_hz == 0.0` ⇒ baseband
416/// passthrough, exactly like `BpskMod`).
417///
418/// Consumes whole `bits_per_ofdm_symbol()`-sized bit chunks, produces whole
419/// `samples_per_ofdm_symbol()`-sized IQ chunks; a partial trailing chunk is
420/// a no-op, with no cross-call buffering. All intermediate buffers are
421/// struct fields sized once in `new()`.
422pub struct OfdmMod {
423    bits_per_symbol: usize,
424    samples_per_symbol: usize,
425    gain: f32,
426    rf_hz: f32,
427    mapper: MapperKind,
428    grid_map: GridMap,
429    ifft: IfftBlock,
430    cp_insert: CyclicPrefixInsert,
431    rot: Rotator,
432    // scratch, sized once in new()
433    syms_scratch: Vec<C32>,
434    freq_scratch: Vec<C32>,
435    time_scratch: Vec<C32>,
436    cp_scratch: Vec<C32>,
437}
438
439impl OfdmMod {
440    pub fn new(cfg: &OfdmConfig) -> Self {
441        let grid = CarrierGrid::from_plan(&cfg.carrier_plan);
442        let n_fft = cfg.carrier_plan.n_fft();
443        let cp_len = cfg.carrier_plan.cp_len();
444        let num_data = grid.num_data_carriers();
445
446        Self {
447            bits_per_symbol: cfg.bits_per_ofdm_symbol(),
448            samples_per_symbol: cfg.samples_per_ofdm_symbol(),
449            gain: cfg.gain,
450            rf_hz: cfg.rf_hz,
451            mapper: MapperKind::new(cfg.constellation),
452            grid_map: GridMap::new(grid),
453            ifft: IfftBlock::new(n_fft),
454            cp_insert: CyclicPrefixInsert::new(n_fft, cp_len),
455            rot: Rotator::new(cfg.rf_hz, cfg.fs),
456            syms_scratch: vec![C32::default(); num_data],
457            freq_scratch: vec![C32::default(); n_fft],
458            time_scratch: vec![C32::default(); n_fft],
459            cp_scratch: vec![C32::default(); n_fft + cp_len],
460        }
461    }
462
463    pub fn set_gain(&mut self, g: f32) {
464        self.gain = g;
465    }
466
467    /// Convenience wrapper mirroring `Ft8Mod::modulate()`: modulates all of
468    /// `bits`, zero-padding a final partial symbol.
469    pub fn modulate(&mut self, bits: &[u8]) -> Vec<C32> {
470        let bps = self.bits_per_symbol;
471        if bps == 0 {
472            return Vec::new();
473        }
474        let n_symbols = bits.len().div_ceil(bps);
475        let mut padded = bits.to_vec();
476        padded.resize(n_symbols * bps, 0);
477
478        let mut out = vec![C32::default(); n_symbols * self.samples_per_symbol];
479        let mut bits_read = 0usize;
480        let mut samples_written = 0usize;
481        while bits_read < padded.len() {
482            let wr = self.process(
483                &padded[bits_read..],
484                &mut out[samples_written..samples_written + self.samples_per_symbol],
485            );
486            if wr.in_read == 0 {
487                break;
488            }
489            bits_read += wr.in_read;
490            samples_written += wr.out_written;
491        }
492        out
493    }
494}
495
496impl Block for OfdmMod {
497    type In = u8;
498    type Out = C32;
499
500    fn process(&mut self, input: &[u8], output: &mut [C32]) -> WorkReport {
501        if input.len() < self.bits_per_symbol || output.len() < self.samples_per_symbol {
502            return WorkReport::default();
503        }
504
505        let map_wr = self
506            .mapper
507            .process(&input[..self.bits_per_symbol], &mut self.syms_scratch);
508        let grid_wr = self
509            .grid_map
510            .process(&self.syms_scratch, &mut self.freq_scratch);
511        let ifft_wr = self
512            .ifft
513            .process(&self.freq_scratch, &mut self.time_scratch);
514        let cp_wr = self
515            .cp_insert
516            .process(&self.time_scratch, &mut self.cp_scratch);
517
518        debug_assert_eq!(map_wr.in_read, self.bits_per_symbol);
519        debug_assert_eq!(grid_wr.out_written, self.ifft.n_fft());
520        debug_assert_eq!(ifft_wr.out_written, self.ifft.n_fft());
521        debug_assert_eq!(cp_wr.out_written, self.samples_per_symbol);
522
523        let g = self.gain;
524        let n = self.samples_per_symbol;
525        if self.rf_hz != 0.0 {
526            for (out, &s) in output[..n].iter_mut().zip(self.cp_scratch[..n].iter()) {
527                let r = self.rot.next();
528                *out = C32::new(
529                    g * s.re.mul_add(r.re, -s.im * r.im),
530                    g * s.im.mul_add(r.re, s.re * r.im),
531                );
532            }
533        } else {
534            for (out, &s) in output[..n].iter_mut().zip(self.cp_scratch[..n].iter()) {
535                *out = C32::new(g * s.re, g * s.im);
536            }
537        }
538
539        WorkReport {
540            in_read: self.bits_per_symbol,
541            out_written: n,
542        }
543    }
544}