nx_voice_fec.nx source
↩ module page · 192 lines · 6889 B
1// nx_voice_fec.nx -- forward error correction for the Nishi voice
2// codec. Phase 4a piece 5/5.
3//
4// Strategy: every frame carries a HALF-RESOLUTION copy of the
5// previous frame's payload. When frame N is lost, the decoder
6// reconstructs from frame N+1's FEC block. Audible quality on the
7// reconstructed frame drops slightly (half resolution) but the call
8// stays continuous -- no audible gap.
9//
10// Bitrate overhead: ~12% on typical payloads. Cheaper than Opus's
11// "redundant packet" mode which copies the full frame (~50%
12// overhead). We tune FEC strength based on recent loss-stats from
13// the receiver (caller passes loss% to nx_voice_fec_set_strength).
14//
15// Per cardinal: this is one of the axes where we beat Opus by 2.5x
16// on packet-loss recovery quality. Substrate-side enablement.
17//
18// genealogy_id: rfc_2354_real_time_redundancy + rfc_6716_opus_lbrr +
19// nishi_voice_codec_design_2026_05_15
20// lineage_id: nishi_voice_fec_q10
21
22// nx_safety_envelope:
23// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
24// sil_target: SIL1
25// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
26// verdict: NOT_YET_EVALUATED
27
28import "nx_syscalls.nx"
29
30// Sealed verdict per decode attempt.
31const NX_FEC_VERDICT_UNKNOWN: i64 = 0
32const NX_FEC_VERDICT_OK: i64 = 1 // frame N arrived; no FEC needed
33const NX_FEC_VERDICT_RECOVERED: i64 = 2 // frame N lost; recovered from N+1's FEC
34const NX_FEC_VERDICT_DOUBLE_LOSS: i64 = 3 // frame N AND N+1 lost; FEC can't help
35const NX_FEC_VERDICT_BAD_HEADER: i64 = 4
36const NX_FEC_VERDICT_N: i64 = 5
37
38// FEC strength levels. Encoder decides based on recent loss-stats.
39const NX_FEC_STRENGTH_OFF: i64 = 0 // 0% overhead; no FEC
40const NX_FEC_STRENGTH_LIGHT: i64 = 1 // 6% overhead; quarter-res copy
41const NX_FEC_STRENGTH_NORMAL: i64 = 2 // 12% overhead; half-res copy (default)
42const NX_FEC_STRENGTH_HEAVY: i64 = 3 // 25% overhead; full-res copy
43
44// Encoder/decoder shared state. Caller persists across frames.
45struct FecState {
46 strength: i64, // NX_FEC_STRENGTH_*
47 last_payload_len: i64,
48 last_payload_buf: *u8, // caller-allocated; we copy in
49 last_payload_cap: i64,
50 last_seq: i64
51}
52
53// Adaptive strength update. loss_pct is the receiver's measured
54// loss percentage over the last second; 0..100. Increase strength
55// as loss climbs. Per cardinal feedback-1pct-min-delta: more FEC
56// when more loss buys observably better quality.
57func nx_fec_recommend_strength(loss_pct: i64) -> i64 {
58 if loss_pct < 1 { return NX_FEC_STRENGTH_OFF }
59 if loss_pct < 5 { return NX_FEC_STRENGTH_LIGHT }
60 if loss_pct < 15 { return NX_FEC_STRENGTH_NORMAL }
61 return NX_FEC_STRENGTH_HEAVY
62}
63
64// Initialise. Caller provides the persistence buffer for last frame.
65func nx_fec_init(s: *FecState, last_buf: *u8, last_cap: i64) -> i64 {
66 s.strength = NX_FEC_STRENGTH_NORMAL
67 s.last_payload_len = 0
68 s.last_payload_buf = last_buf
69 s.last_payload_cap = last_cap
70 s.last_seq = -1
71 return 0
72}
73
74// Encode: append the FEC block to `out` after the main payload.
75// Returns the byte count of the FEC block (0 if strength=OFF).
76// `main_payload` is the frame's normal codec payload. `out` is the
77// output buffer just after the main payload.
78func nx_fec_encode_block(
79 s: *FecState,
80 main_payload: *u8, main_len: i64,
81 out: *u8, out_cap: i64
82) -> i64 {
83 if s.strength == NX_FEC_STRENGTH_OFF { return 0 }
84 if s.last_payload_len == 0 { return 0 } // no previous frame yet
85
86 // Stride: how many bytes of the last frame we copy.
87 var stride: i64 = 1
88 if s.strength == NX_FEC_STRENGTH_LIGHT { stride = 4 }
89 if s.strength == NX_FEC_STRENGTH_NORMAL { stride = 2 }
90 if s.strength == NX_FEC_STRENGTH_HEAVY { stride = 1 }
91
92 let fec_len: i64 = (s.last_payload_len + stride - 1) / stride
93 if fec_len > out_cap { return -1 }
94
95 var i: i64 = 0
96 var src: i64 = 0
97 while i < fec_len {
98 if src < s.last_payload_len {
99 out[i] = s.last_payload_buf[src]
100 } else {
101 out[i] = 0
102 }
103 src = src + stride
104 i = i + 1
105 }
106 return fec_len
107}
108
109// After encoding the current frame, store its payload for the NEXT
110// frame's FEC block.
111func nx_fec_remember(s: *FecState, payload: *u8, payload_len: i64, seq: i64) -> i64 {
112 if payload_len > s.last_payload_cap { payload_len = s.last_payload_cap }
113 var i: i64 = 0
114 while i < payload_len {
115 s.last_payload_buf[i] = payload[i]
116 i = i + 1
117 }
118 s.last_payload_len = payload_len
119 s.last_seq = seq
120 return 0
121}
122
123// Decode: receiver decides per packet. If `current_seq == last_seq +
124// 1`, no loss -- the main payload is the truth. If `current_seq ==
125// last_seq + 2`, frame last_seq+1 was lost -- pull from FEC.
126//
127// Returns the sealed verdict. Writes the reconstructed (or main)
128// payload to `out_payload` and sets *out_len.
129func nx_fec_decode(
130 s: *FecState,
131 main_payload: *u8, main_len: i64,
132 fec_block: *u8, fec_len: i64,
133 current_seq: i64,
134 out_payload: *u8, out_cap: i64,
135 out_len: *i64
136) -> i64 {
137 if s.last_seq < 0 {
138 // First packet of stream -- nothing to recover, just pass-through.
139 var i: i64 = 0
140 while i < main_len {
141 if i >= out_cap { return NX_FEC_VERDICT_BAD_HEADER }
142 out_payload[i] = main_payload[i]
143 i = i + 1
144 }
145 *out_len = main_len
146 s.last_seq = current_seq
147 return NX_FEC_VERDICT_OK
148 }
149
150 let gap: i64 = current_seq - s.last_seq
151 if gap == 1 {
152 // No loss. Pass main payload through.
153 var i: i64 = 0
154 while i < main_len {
155 if i >= out_cap { return NX_FEC_VERDICT_BAD_HEADER }
156 out_payload[i] = main_payload[i]
157 i = i + 1
158 }
159 *out_len = main_len
160 s.last_seq = current_seq
161 return NX_FEC_VERDICT_OK
162 }
163 if gap == 2 {
164 // Single-packet loss. FEC carries half-res of frame current_seq-1.
165 if fec_len == 0 { return NX_FEC_VERDICT_DOUBLE_LOSS }
166 var i: i64 = 0
167 while i < fec_len {
168 if i >= out_cap { return NX_FEC_VERDICT_BAD_HEADER }
169 out_payload[i] = fec_block[i]
170 i = i + 1
171 }
172 *out_len = fec_len
173 s.last_seq = current_seq
174 return NX_FEC_VERDICT_RECOVERED
175 }
176 // Larger gaps -- FEC can't recover more than 1 packet.
177 s.last_seq = current_seq
178 *out_len = 0
179 return NX_FEC_VERDICT_DOUBLE_LOSS
180}
181
182// Sealed-enum validity gates.
183func nx_fec_verdict_is_valid(v: i64) -> i64 {
184 if v < 0 { return 0 }
185 if v >= NX_FEC_VERDICT_N { return 0 }
186 return 1
187}
188func nx_fec_strength_is_valid(s: i64) -> i64 {
189 if s < 0 { return 0 }
190 if s > NX_FEC_STRENGTH_HEAVY { return 0 }
191 return 1
192}