sotto

sotto on the C5517: C port and budget

2026-10-31, C55x measurements 2026-11-02. The float C port is in c/, and c/README.md explains how to build and run it. This page covers what the radio’s DSP needs that the float port doesn’t have yet.

What exists

Where the time goes

Measured on one x86 core with gprof, 60 s of speech:

Part Share of encoder time
Shape VQ, M-best search (12+11+11 bits, M = 8) 90%
Pitch tracker candidates (harmonic-sum refinement, fit error, odd/even check) 9%
Codec 2 analysis (nlp, FFT, refinement, voicing) under 3%

The encoder runs at 3% of real time and the decoder at 0.1%.

The VQ search costs about 700,000 multiply-adds per 20 ms frame:

That is 35 million multiply-adds a second. The C5517 has two 17×17-bit MACs at up to 200 MHz, so in 16-bit fixed point the search would need about 20 to 30 MHz. That is too much to spend on one block when the C4FM modem has to share the DSP.

Memory, and a smaller codebook

The C5517 has 320 KB of on-chip RAM: 64 KB dual-access and 256 KB single-access. Yaesu’s modem and whatever replaces AMBE have to share it. The shipped codebook has 155,648 values. It needs 608 KB as float, 304 KB as 16-bit and 152 KB as 8-bit, so it doesn’t fit as it is.

Smaller layouts were trained on the same data (Speech Commands 600 + LibriSpeech 32 + Codec 2 recordings). They keep the 34 bits, so the frame doesn’t change. Scores are v3.5 on 19 voices, clean channel, 80 ms tracker:

Codebook (bits per stage) Values 8-bit size VQ multiply-adds per frame PESQ ESTOI
12 + 11 + 11 (shipped) 155,648 152 KB 700,000 2.917 0.80
12 + 11 + 11, values rounded to 8 bits 155,648 152 KB 700,000 2.908 0.80
12 + 11 + 11, rounded to 6 bits 155,648 114 KB 700,000 2.898 0.80
9 + 9 + 8 + 8 29,184 29 KB 165,000 2.913 0.80
10 + 8 + 8 + 8 34,048 33 KB 136,000 2.915 0.80
9 + 9 + 8 + 8, M = 16 29,184 29 KB 321,000 2.896 0.80
9 + 9 + 8 + 8, M = 16, rounded to 8 bits 29,184 29 KB 321,000 2.904 0.80
8 + 8 + 6 + 6 + 6 13,376 13 KB 73,000 2.881 0.80

Differences under about 0.02 are within the noise of 19 clips. The small layouts were also trained with Codec 2’s recordings, which include the ve9qrp voice. So read the table as showing that a codebook a fifth the size loses nothing measurable, not that one layout beats another.

v3.8: the 16 kHz layer

2026-11-02. c/sotto_hiss.c adds the v3.8 hiss band (sim/hiss_band.py, docs/unvoiced-hiss.md) to the float port. make -C c builds it by default (VARIANT=v3.8); VARIANT=v3.7 gives the 8 kHz library as before. The frame is v3.7’s, so a v3.7 decoder plays a v3.8 stream without the high band, and the 8 kHz functions keep working in a v3.8 build.

What the layer does. The encoder takes 16 kHz speech, decimates it for the core (scipy’s resample_poly filter, 41 taps, run with its 20 samples of look-ahead so the two sides see the same 8 kHz samples), and runs the v3.8 voicing rules over a ring of the core’s frames: onset recoding, the whisper rule, voicing smoothing, the burst rule and the sibilant rule. All-unvoiced frames then get the 6-bit 4-8 kHz index in their pitch field and one bit of the speaker-shape side channel. Both are measured against the encoder’s own decoder, a second core decoder inside the encoder, as the Python’s _low16 does. The decoder upsamples the core’s output and adds a 4-8 kHz noise band per 10 ms STFT frame: consonant hiss from the index, a vowel-following layer from the decoded 0-4 kHz spectrum and the speaker shape, blended per bin. The core itself changed in three places for v3.8: unvoiced halves are synthesised 50 Hz apart whatever the pitch field says, upward-tilted ones 3 dB louder, and a voiced second half between two unvoiced frames keeps its own pitch.

Delay. The Python model is not causal, and the port keeps its behaviour at the price of look-ahead:

Stage v3.7 v3.8
Encoder: pitch tracker 90 ms 90 ms
Encoder: resampler - 10 ms
Encoder: voicing rules (SOTTO_RULE_LAG frames) and the index - 220 ms (lag 10)
Decoder: interpolation 20 ms 20 ms
Decoder: predictor on the decoded audio, frame lag, flicker bridging - 80 ms
Codec in all 110 ms 420 ms

The 16 kHz encoder emits its first frame on the 17th call and the decoder’s output is 5 frames late (SOTTO_DEC16_DELAY_FRAMES); the -16 tools remove that delay. The rule look-ahead is a build option: make -C c RULE_LAG=5. The whisper rule is what needs it: the Python judges a run of mixed frames on the whole run, the C on its first RULE_LAG + 1 frames, and SottoV38(whisper_lag=...) makes the Python do the same so the two can be compared. The other rules look at most 3 frames ahead and the index one frame.

Checked against Python (tools/check_c_port38.py, 7 s of five clips: three test voices and the two whispered bench recordings, docs/results/c_port38_lag*.json). “Lag” is the share of frames whose 48 bits match the Python with the same whisper look-ahead; “full” the share against the Python as Jeff heard it (whole-run whisper judgement):

RULE_LAG Encoder delay 2428 m p020 m 6313 f whisper (Jeff) whisper (wife)
20 520 ms 100 / 100 98.3 / 98.3 100 / 100 100 / 83.4 100 / 100
10 (default) 320 ms 100 / 100 98.3 / 98.3 100 / 100 100 / 100 100 / 100
5 220 ms 92.9 / 92.9 97.4 / 97.4 100 / 100 89.1 / 89.1 90.0 / 90.0
3 180 ms 93.1 / 89.1 96.6 / 96.6 100 / 100 82.9 / 77.7 87.9 / 87.9
1 140 ms 96.9 / 86.3 96.3 / 96.3 97.7 / 97.7 73.1 / 73.1 85.4 / 72.9

Kept as the Python has it. Two things in the Python look accidental but are part of the sound Jeff approved, so the C keeps them: mixed frames’ consonant hiss is scaled by their mask value (3, so -3 dB + 9.5 dB), and the concealment fade is applied twice to the consonant layer (through the decoded envelope and again explicitly).

For the DSP. The layer adds, per 10 ms, two 320-point real FFTs in the decoder (one on the decoded audio for the predictor, one inverse for the hiss) and in the encoder one on the input, one on the local decoder’s output, plus the whole core decoder running inside the encoder. The radio is half duplex, so the encoder’s local decoder reuses the decoder’s cycles; the FFTs are a few MHz on the C5517’s accelerator. The encoder’s ring of 128 frames and 256 STFT frames of features (sotto 600 / 400 hiss reaches about 140 back, 2400 and 1200 fewer than 64) is about 90 KB as doubles and would shrink to 16-bit fields and a ring sized to the mode. The running percentile of the side channel keeps every frame level so far (the Python does); a 1 dB histogram would replace it on the DSP, changing a vowel-frame decision only when a level sits within the bin.

sotto 1200 hiss. 2026-11-06. The same layer runs under sotto 1200 at 48 bits per 40 ms (sotto_lr_enc16 / sotto_lr_dec16 in c/sotto_hiss.c, float only for now): 14 of the 16 test voices bit-exact with sim/sotto_lr_hiss.py, the other two one superframe off. Details, delay and check in docs/sotto-1200-hiss.md (“C port”).

sotto 600 / 400 hiss. 2026-11-07. And under sotto 600 / 400 (sotto_vlr_enc16 / sotto_vlr_dec16, float only): bit-exact with sim/sotto_vlr_hiss.py on the 16 test voices at both rates. Its decoder is the float twin of the K6’s fixed-point one: both now include sotto_vlr_dec.inc. Details in docs/sotto-vlr.md (“Hiss band”, “C port”).

Fixed point: status

The fixed-point build is in c/fixed/ (make fixed). It is new MIT code, written from the float model’s behaviour, not a conversion of Codec 2. It uses only integers: tables generated by tools/gen_fx_tables.py, log2 and exp2 by table, levels in dB Q8, and frequencies and phases as Q32 turns so phase accumulators wrap for free. It is built with -DFX_CHECK, which stops the program if any sum of products would overflow the C55x’s 40-bit accumulator. The rules that keep the x86 build and a C55x build the same are at the top of c/fixed/fx.h.

Decoder: done (c/fixed/sotto_fx_dec.c, the same sotto_dec_* API).

Encoder: done (c/fixed/sotto_fx_enc.c, the same sotto_enc_* API).

v3.8 layer: done (c/fixed/sotto_fx_hiss.c, the same sotto_enc16_* / sotto_dec16_* API as the float layer, make fixed with the default VARIANT=v3.8; -16 on the tools). 2026-11-02.

Fixed point on the C55x: measured

2026-11-02. Built with TI’s C5500 compiler 4.4.1 (-O3, large memory model, the C5517’s CPU revision 3.3) and run on TI’s cycle-accurate C55x Rev 3.0 CPU simulator. The program is c/fixed/c55x/bench.c. It counts the cycles of every sotto_encode and sotto_decode call, and c/fixed/c55x/README.md explains how to set up the tools and run it. The input is 10 s of ve9qrp (500 frames). The DSP’s bits and audio are identical to the x86 build’s.

Cycles. These are MHz of the C5517’s 200 MHz. “Mean” is the average over the 500 frames, and “worst frame” is the most expensive single frame, which is what has to fit in real time.

  Estimate (2026-11-01) First C55x build Now: mean Now: worst frame
Encoder 25–50 802 188 212
Decoder 4–8 106 36.7 43.9
Encoder, codebook unpacked (-DFX_CB_UNPACKED, 1 s)     169 192

The radio is half duplex, so only one of the two runs at a time. The encoder doesn’t fit yet: at its worst frame it needs more than the whole DSP, before the modem and FEC.

Memory. These are bytes; one C55x word is 2 bytes.

  Estimate (x86 sizes) C55x
Encoder state 15.7 KB 16.2 KB
Decoder state 0.8 KB 0.8 KB
Tables 43 KB 45.7 KB (76.4 KB with the codebook unpacked)
Code 18 KB 17.4 KB (encoder 11.9, decoder 3.6, arithmetic 1.9), plus a few KB of TI’s run-time library
Stack not estimated 6.0 KB at most (first reported as 14.4 KB, see below)
Total about 78 KB about 90 KB of the C5517’s 320 KB

The RAM estimate held. The tables grew because the C55x has no 8-bit type: the codebook is stored two values to a 16-bit word, padded to 20 values a vector, and the other small tables take a word a value. Most of the stack is the encoder’s copy of a whole spectrum (2 KB) and the codebook search’s 8 paths (2.4 KB). The first version of this page gave 14.4 KB: the benchmark read the linker’s stack symbols, which are byte addresses, as word addresses, and so counted 8 KB of other memory as stack (fixed 2026-11-02).

Where the encoder’s time goes (1 s, MHz averaged over frames, from the profiling build, whose timer calls add about 7% to the total):

Block MHz What it does
Codebook search 48 8,704 vectors a frame (8 paths), 10 packed words each
512-point FFT 35 4 a frame: one per 10 ms analysis, and one redone for each frame the tracker decides
Pitch refinement (refine) 36 harmonic sums at 20 trial pitches for each pitch candidate
Fit error (ln_fit_error) 32 the voicing fit for each candidate: 2 divisions per harmonic
nlp pitch estimator 18 64-point DFT at 115 bins, and the 48-tap filter
Everything else in the spectra (window, scaling, bin powers) 15  
Everything else about 17  
Decoder: minimum phase / overlap-add sums / the rest 16 / 10 / 10  

Why the estimate was 4 to 5 times too low. It assumed one or two cycles per inner-loop step, which is what hand-written C55x assembly gets. Compiled C gets much less:

What changed for the C55x (all in c/fixed/, the x86 build gives the same bits):

v3.8 on the C55x (2026-11-02). -DBENCH16 runs 16 kHz audio through the v3.8 layer (sotto_encode16 / sotto_decode16). The input is LibriSpeech 198-209-0000 (the female clip, 13.9 s, resampled to 16 kHz), since ve9qrp exists only at 8 kHz. The DSP’s bits match the x86 build’s and its audio matches the x86 decoder’s sample for sample (5 frames later: sotto_dec -16 removes the layer’s delay, bench.c doesn’t).

  v3.7 (8 kHz, ve9qrp) v3.8: mean v3.8: worst frame Estimate for the layer
Encoder 188 / 212 288 328 core + 45–50
Decoder 36.7 / 43.9 92.3 106.8 core + 5–10

Where the layer’s time goes (2 s, profiling build, MHz):

Encoder MHz Decoder MHz
8 kHz core encoder 204 8 kHz core decoder (80 harmonics in unvoiced halves) 41
2:1 decimator (41 taps) 7 1:2 interpolator 10
input STFT and band levels 16 stage A: STFT of the decoded audio, predictor 17
local core decoder (the encoder’s own copy) 41 stage B: hiss synthesis (inverse FFT, overlap-add) 24
local 1:2 interpolator and STFT 27    
rules, index, side bit 4    

The layer adds about 80 MHz to the encoder and 50 MHz to the decoder, far more than the estimate. The causes are the same as for the core: a 320-point STFT costs over 100,000 cycles in compiled C, and each side runs four per 20 ms; the 41-tap resamplers multiply 32 × 16 bits. The encoder also decodes its own frames, which costs as much as the decoder’s core. (The 8 kHz core itself got about 7 MHz slower with v3.8’s core changes: 194 MHz on the 1 s ve9qrp run with C55X_VARIANT=v3.7, against 187 at PR #11.)

Fixes in this round (all exact, the same bits and samples on x86 before and after):

Memory with v3.8 (both directions allocated, as in bench.c):

  Size
16 kHz encoder state (its core encoder 16.2 KB and local decoder 0.8 KB extra) 80.1 KB (+17 KB)
16 kHz decoder state (its core decoder 0.8 KB extra) 23.5 KB (+0.8 KB)
Tables 49.4 KB
Code (layer 13.5 KB, encoder 12.3, decoder 4.0, arithmetic 1.9) 31.7 KB
Stack 6.9 KB at most
Total about 210 KB of 320 KB

-DFX_PCT_MAX=3000 (30 s of percentile history instead of 120 s) saves 36 KB.

Next, to fit with room for the modem. In order of MHz saved for the work:

  1. FFT in assembly, or on the C5517’s FFT accelerator (16-bit data, which changes the low bits and needs a check against float): 35 MHz to under 10.
  2. Codebook search: unpack the codebook (29 KB more, saves about 20 MHz at the worst frame), or write the search with the C55x’s dual multiply-accumulate (2 vectors at once).
  3. Voicing fit: one reciprocal per harmonic instead of two divisions, and the fit error from the sums instead of a second pass. That changes rounding, so it needs the 19-voice check again. About 15 to 20 MHz.
  4. Keep the decided frame’s spectrum instead of redoing its FFT: saves a quarter of the FFT time for 18 KB more state.
  5. Decoder: the overlap-add as one 256-point inverse FFT, and the minimum phase with FFTs, as Codec 2 does. About 15 MHz.

With 1 to 3, the encoder should land near 100 MHz at its worst frame, and the decoder near 25 MHz. A C5517 board is still needed to confirm the simulator against real memory wait states.

Fixed point on the C55x: the speedup round

2026-11-02, after PR #18. The same simulator and inputs as above. Every change keeps the x86 build and the DSP build identical to each other (checked on the 8 kHz and 16 kHz runs); every change but one also keeps the bits of PR #18.

  Before (PR #11 for 8 kHz, PR #18 for 16 kHz): mean / worst frame Now: mean / worst frame
v3.7 encoder (8 kHz, ve9qrp 10 s) 188 / 212 114.9 / 127.9
v3.7 decoder 36.7 / 43.9 29.7 / 36.9
v3.8 encoder (16 kHz, LibriSpeech 198, 13.9 s) 288 / 328 181.1 / 209.3
v3.8 decoder 92.3 / 106.8 67.3 / 81.7

The 8 kHz encoder now fits with about 70 MHz to spare at its worst frame. The v3.8 encoder still doesn’t: its worst frame needs 209 MHz of the 200, before the modem and FEC.

What changed. Encoder MHz on the 1 s 8 kHz profile (the profiling build’s timer calls add a few percent), measured after each step:

Step Encoder Decoder Bits
PR #18 206.9 36.9  
Real-input FFT: the 512-point spectra as one 256-point complex FFT plus a split step (fx_rfft_pack, fx_rfft_split), and the FFT’s loops reordered so each twiddle is read once a group. The v3.8 layer’s 320-point STFTs and its inverse go the same way at 160 points 190.5   about 1% of frames differ
Codebook search: the codebook unpacked to one value per word (29 KB more) and the dot products in assembly with the dual multiply-accumulate (cb_dots.asm, two vectors at a time) 169.2   same
Voicing fit: the two divisions of a harmonic share one normalisation (div_q15_2) 150.6   same
Minimum phase from precomputed even/odd tables (fx_mp_cos, fx_mp_sin, 8 KB), using the symmetry of the 64-point transform; the refinement’s harmonic sums in assembly (hsum.asm) 143.9 29.7 same
Pitch estimator: the 64-point DFT in assembly, cosine and sine in one dual multiply-accumulate on a circular buffer (nlp_dft.asm), and the 48-tap filter as two 16-bit loops on split high and low halves; the codebook search skips runs of 8 vectors whose smallest error can’t make the list 126.4 29.7 same

By block: codebook search 48 → 21 MHz, FFTs 35 → 18, voicing fit 32 → 16, pitch estimator 18 → 4.5, refinement 36 → 29, minimum phase 16 → 9. In the v3.8 layer the 41-tap resamplers also run on split halves, and its 320-point FFT’s radix-5 stage is shared by the 160-point transforms.

The runs are in docs/results/c55x_bench_speedup_*.txt and the profiles in docs/results/c55x_profile_speedup.txt.

The real-input FFT gives the same spectrum to within a unit or two in the last place, but not bit for bit, so a frame changes where two choices were nearly tied. On ten 16 kHz clips 97 to 100% of frames are identical, on four 8 kHz clips 98 to 100%, and the mean PESQ-NB of the 8 kHz set is 2.849 before and after. The A/B page (fixed-point WebAssembly builds of PR #18 and this round, side by side) is for listening.

Memory. The tables grow by 37 KB: 29 KB for the unpacked codebook and 8 KB for the minimum-phase tables. C55X_PACKED=1 (tools/c55x_build.sh) keeps the packed codebook and its C search, which gives the same bits more slowly. With v3.8 the total is about 250 KB of the C5517’s 320 KB (-DFX_PCT_MAX=3000 still saves 36 KB).

Where the encoder’s time goes now (8 kHz, 1 s, profiling build, MHz): codebook search 21 (of which the dot products 10), pitch refinement 29 (of which the 2^48 reciprocals 8), the two spectra 16 each (FFT 18 of that, all four), voicing fit 16, pitch estimator 5, the rest about 20. Decoder: overlap-add 10, minimum phase 9, the rest 10.

Next. The C5517’s FFT accelerator can’t be simulated: its 16-bit data would change the low bits, and it needs a board to measure. On the simulator the remaining large items are compiled C: the refinement’s reciprocals, the voicing fit’s per-harmonic loop, and the decoder’s overlap-add (one 256-point inverse FFT, as Codec 2 does, would replace it but changes the bits). The encoder still runs its own copy of the decoder for the v3.8 layer (33 MHz).

Fixed point on the C55x: the second speedup round

2026-11-02, after PR #19. The same simulator and inputs. Every change keeps the bits of PR #19: the x86 build (with FX_CHECK) and the DSP build give the same bitstreams and the same decoded audio on four 8 kHz clips and two 16 kHz clips, and every assembly kernel is checked against its C version on random inputs (c/fixed/c55x/kernels.c).

  PR #19: mean / worst frame Now: mean / worst frame
v3.7 encoder (8 kHz, ve9qrp 10 s) 114.9 / 127.9 81.8 / 91.1
v3.7 decoder 29.7 / 36.9 20.1 / 24.8
v3.8 encoder (16 kHz, LibriSpeech 198, 13.9 s) 181.1 / 209.3 137.0 / 156.9
v3.8 decoder 67.3 / 81.7 54.7 / 64.3

Both encoders now fit the C5517’s 200 MHz with margin: the 8 kHz pair takes 102 MHz on average and 116 on its worst frames, the 16 kHz pair 192 on average. At 16 kHz the worst encoder and decoder frames (157 + 64) would exceed 200 only if they coincided, which the percentile history shows they don’t on this clip; a real radio would still want the encoder’s local decoder dropped or the synthesis changed (below) before relying on that. The 8 kHz encoder state grew by 9 K words (the spectrum ring). The 16 kHz one shows 18 K words less only because this bench keeps 30 s of the layer’s percentile history instead of 120 s (-DFX_PCT_MAX=3000, so that the heap fits one 64K-word page); the bits are the same for clips under 30 s.

What changed. Encoder MHz on the 1 s 8 kHz profile, measured after each step. These are the profiling build’s numbers: each pair of its timer marks costs about 294 cycles, and the two blocks with hundreds of calls a frame (the voicing fit, the reciprocals) carry about 12 MHz of that, so the final table above (no marks) is lower.

Step Encoder Decoder
The decided frame’s spectrum kept from analysis (a ring of LAG + 2 spectra) instead of a second FFT; the reciprocal by table seed and Newton step, in C 140.8 29.8
The reciprocal in assembly (recip48.asm: 16 x 16-bit products only, products mod 2^32 from 15-bit halves); the spectrum’s bin loops in assembly (bins.asm: largest bin, 16-bit copy, powers) 129.5 29.8
The voicing fit’s three loops in assembly (hfit.asm: the sums with the dual multiply-accumulate, the error, the band power) 129.2  
The FFT’s two butterfly loops in assembly (fft.asm) 127.1  
The voicing fit’s two divisions in assembly (divq15.asm: two rounds of 16 conditional subtractions each, instead of the 150-cycle library division); the codebook search’s residual in assembly (cb_resid.asm) and its recon update as 16 x 16 products; the decoder’s overlap-add two samples at a time with the cosine table as a circular buffer (oa_pair.asm) and its minimum-phase dot products with the dual multiply-accumulate (mp_sums.asm); fx_mul32 (four 16 x 16 products) in place of the compiler’s 32 x 32 library call in the dB/log helpers, fx_exp2, fx_cos, env_at and min_phase 119.5 20.2
The v3.8 layer’s radix-5 stage in assembly (fft5.asm, 16 kHz only; its 5.3 MHz on the 16 kHz profile mostly gone, not re-profiled)    

By block (8 kHz): voicing fit 32 → 9 (the loops turned out to be a third of it; the two divisions and the per-call arithmetic were the rest), the spectra 16 → 11, minimum phase 9 → 4.6, overlap-add 10 → 6.7, FFTs 5.7 → 4.2. At 16 kHz the profile went 188 → 169 MHz (encoder) and 70 → 57 (decoder) before the radix-5 kernel; the STFT’s 160-point FFT is 15 of its 22 MHz, the radix-5 stage 5.3 of that.

The profiles are in docs/results/c55x_profile_speedup2.txt, the final runs in docs/results/c55x_bench_speedup2_ve9qrp10s.txt and docs/results/c55x_bench_speedup2_v38_libri198.txt.

How the kernels stay exact. Each kernel does the C’s integer arithmetic in the C’s order; what changes is only how the C55x reaches the numbers. The recurring tools: a 32-bit value split into a signed high half and a 15-bit low half (FX_HI, FX_LO), so a 32 x 16 or 32 x 32 product is two or four single-cycle 16 x 16 ones; the 40-bit accumulator for sums (M40 set inside the kernel, cleared on return); the dual multiply-accumulate where two sums share an operand (the real and imaginary parts against one window value, the high and low halves against one table value); EXP for bit lengths (it returns 0 for a zero accumulator, which divq15.asm checks first); SUBC for an exact 32-bit by 16-bit division; a circular buffer (BSA45, BK47) for a table index that wraps. kernels.c caught the only two mistakes made along the way (EXP’s offset is 30, not 31, and its zero case), both before any codec run.

Memory. The spectrum ring adds 2 x 257 x 4 x (LAG + 2) words to the encoder state; fx_rcp_tab adds 256 words. The 8 kHz bench’s heap grew to 0xA000 words; the 16 kHz bench now runs with -DFX_PCT_MAX=3000 (clips up to 30 s, 36 KB less state), so that its heap stays inside one 64K-word page.

Next. What is left in compiled C at 8 kHz, by the profile: the codebook search’s list pass (4 MHz), the pitch refinement’s per-candidate loop around fx_hsum and recip48, the pitch-cost bookkeeping in decide and frame_costs, and the pitch estimator (4.5). The encoder still runs its own copy of the decoder for the v3.8 layer (22 MHz now).

Fixed point on the Cortex-M4 (TYT MD-UV380/390)

2026-11-02, after PR #21; v3.8 and the SMLALD round after PR #22. Can an OpenRTX radio run the v3.7 encoder, and v3.8? The TYT MD-UV380/390 (also Module17 and the CS7000) runs an STM32F405: a Cortex-M4 at 168 MHz with 192 KB of RAM and 1 MB of flash. OpenRTX already runs Codec 2 3200 there, through one file (openrtx/src/core/audio_codec.c, 160 samples at 8 kHz a frame, the same framing as sotto). There is no board here, so the numbers come from a model: the fixed-point C built bare-metal for the M4 (tools/cm4_build.sh, clang), run frame by frame in an instruction emulator, each instruction costed with the M4’s published timings (c/fixed/cm4/). Expect about +-15 % against real hardware. The compiler is clang, while OpenRTX uses GCC.

v3.7, 8 kHz, MHz mean / worst frame Encoder Decoder
main after PR #21, -O2 (ve9qrp 10 s) 125.1 / 134.7 27.5 / 35.4
main after PR #21, -Os 171.8 / 181.5 27.5 / 35.7
PR #22, -O2 (four clips: ve9qrp, david4, vk2tpm, kristoff) 85.0-87.0 / 91.6-92.6 22.7-27.4 / 34.3-42.5
PR #22, -Os 107.2-109.3 / 114.5-115.3 22.6-27.4 / 34.6-43.2
Now, -O2 (the same four clips) 78.3-80.4 / 85.0-86.0 13.5-15.7 / 18.9-22.8
Now, -Os 104.4-106.6 / 111.7-112.6 14.1-16.1 / 19.0-22.6
Flash wait states, if the tables stay in flash (5 at 168 MHz, ART accelerator modelled; PR #22 build) +14.2 / +15.0  

In every run the M4 build’s bitstream equals the x86 build’s (--bits, then cmp). The RAM is 35 KB of encoder and decoder state plus 5-8 KB of stack. The flash is 131 KB at -O2 and 102 KB at -Os, of which the unpacked codebook is about 60 KB.

Verdict: the encoder fits. Its worst frame at -O2 with the tables in flash is about 100 MHz of the 168, leaving about 65 MHz for the M17 modulator, audio DMA and the UI while transmitting. At -Os it is about 127 MHz, which is tighter, so the sotto files should be built -O2 (a per-file flag in OpenRTX’s meson build). For scale, the M17 project timed Codec 2 3200’s encoder at about 7 ms a frame, 59 MHz, on the same chip with GCC -Os (m17project.org, 2025-12-29). sotto’s encoder is about 1.4 to 1.7 times that. The decoder, at 19-23 MHz worst, is not a concern.

What carried over from the C55x rounds. The algorithmic changes in C carried over as they were: the spectrum ring instead of a second FFT, and the table-seeded reciprocal. They are already in the “before” row. The nine C55x assembly kernels did not, and they aren’t needed in that form. Most of them split 32-bit products into 16 x 16-bit pieces because the C55x has no wider multiplier. The M4 has single-cycle SMULL and SMLAL (32 x 32 into 64 bits), so on the M4 those splits cost time. Two changes did the work, both in C, both bit-exact:

Step (-O2, ve9qrp) Encoder mean
main after PR #21 125
FX_WIDE_MUL: fx_mul32x16, fx_sq32, fx_mul32, fx_q15_mac2 and the FFT butterfly take the products directly; the same integers for the ranges the split forms already require (FX_HI still checks them under FX_CHECK) about 104
The unpacked codebook (FX_CB_UNPACKED, as on the C55x) searched with SMLAD, two 16 x 16 products per instruction, two vectors at a time so each pair of residual values is loaded once (fx_cb_dots) 85
The NLP pitch DFT with SMLALD and SMLSLD: each sample in both halves of a word against a cos and sin pair from one table word (fx_cs512), so one load and two instructions give r + q and r - q (fx_nlp_dft) 78
The decoder (and the encoder’s local decoder in v3.8): the overlap-add sums with the harmonics packed as re and -im against the same table (fx_oa_pair_ri), and the minimum-phase sums as SMLALD dot products (fx_dot16) decoder 27 to 15

Both are switched on by FX_ARM_DSP, which fx.h defines for any ARM target with the DSP extension. -DFX_ARM_DSP builds the same code on x86 with SMLAD in plain C, and make -C c fixed now also builds sotto_enc_fx_arm / sotto_dec_fx_arm that way. tests/test_vocoder.py::test_fixed_point_arm_path_is_bit_exact checks them against the default build, at 8 kHz and 16 kHz (v3.8).

Where the encoder’s 78 MHz goes now (-O2, MHz with inlining, so callers include their inlined callees): the frame decision with the codebook search 23, the 10 ms analysis (spectrum, NLP pitch DFT, harmonic sums) 15, the pitch-track costs 9, the FFT 6, then many small items (reciprocals, harmonic fit, bin loops, 64-bit divisions) of 1-2 MHz each. The codebook search is already two products a cycle; the next candidates are the harmonic-sum loop (5 MHz) and the butterflies (8 MHz). None is needed to fit.

v3.8 (16 kHz) on the Cortex-M4

VARIANT=v3.8 sh tools/cm4_build.sh adds the hiss-band layer (sotto_fx_hiss.c) with 30 s of percentile history (FX_PCT_MAX=3000, as on the C55x bench), and run.py --16k drives sotto_encode16 / sotto_decode16. Two clips at 16 kHz: LibriSpeech 198-209-0000 (13.9 s, the C55x bench clip) and p020 (11.5 s, a whisper bench clip).

v3.8, 16 kHz, MHz mean / worst frame Encoder Decoder
main after PR #22, -O2 138.0-141.5 / 163.0-163.7 48.4-50.6 / 64.3
Now, -O2 116.1-118.5 / 132.5-133.1 35.1-36.1 / 42.7
Now, -Os 150.1-152.6 / 166.7-167.7 39.8-40.6 / 46.6
Flash wait states, -O2 (150 frames of the Libri clip) +8.8 / +9.7  

The 16 kHz encoder is the v3.7 encoder plus a local decoder (its rules look at decoded audio), the 2:1 resamplers and the 320-point STFT, so the SMLALD work on the decoder counts twice. What it took, on top of the 8 kHz steps above: the resamplers’ 41- and 21-tap filters (down2, up2) as fx_dot16 over the taps’ 16-bit halves (SMLALD, the same sums), 6 MHz.

Bits and samples equal the x86 build (sotto_enc_fx -16, sotto_dec_fx -16) on both clips at -O2 and -Os, and the x86 build’s bits and samples are unchanged from main.

Verdict: v3.8 fits too, at -O2 only. The worst encoder frame with the flash wait states is about 143 MHz of 168, leaving about 25 MHz while transmitting. At -Os it does not fit. RAM is the tighter limit: the encoder state is 77.5 KB (34.5 KB of it the v3.7 core, 12 KB the percentile history), the decoder 24.4 KB, plus about 10 KB of stack; flash is 164 KB. Only one of the two states is needed at a time on a half-duplex radio, and the STM32F405’s 64 KB CCM can hold state (not DMA buffers). Whether the 4-8 kHz band survives the MD-UV380’s own microphone and speaker filtering is untested; OpenRTX samples the microphone at 8 kHz today.

Caveats. The timing model has no bus contention, and its flash model is a sampled guess at the ART accelerator. The bench’s 64-bit division is a simple loop, slower than libgcc’s on wide operands (1.6 MHz). Nothing has run on a real radio. A DWT cycle-counter run on an MD-UV380, or any STM32F4 board, would settle the numbers. The software side of the port is the OpenRTX change: swap codec2_encode/codec2_decode in audio_codec.c for sotto_encode/sotto_decode, send 2400 bps in M17’s 3200 bps voice payload (800 bps left for extra protection), and mark the stream as non-Codec-2 in the LSF type field.

Fixed point: the plan

The C55x has no floating-point unit. Software float would be far too slow for the analysis and synthesis, so the DSP build needs a fixed-point version of every block, not only sotto’s. In order:

  1. Codec 2’s analysis and synthesis in fixed point.
    • FFTs on the C5517’s hardware FFT accelerator, with block floating point for the spectra.
    • nlp, harmonic-sum refinement, amplitudes and the MBE voicing fit.
    • Zero-phase synthesis (128-point FFT, log and exp tables).
    • Postfilter and overlap-add.

    Codec 2 has no official fixed-point version, so this is the largest piece. A fixed-point reference that compiles on x86 comes first, then the C5517 build. The x86 reference uses ETSI-style basic operations, so the same C runs on both.

  2. sotto’s own blocks.
    • The tracker’s costs as base-2 logs in Q8.
    • The rate-K envelope in Q8 dB.
    • 8-bit codebooks with a 32-bit accumulator search.
    • Interpolation and set-energy with a table exp.
  3. Encoder memory. The float encoder keeps 16 frames of 512-point spectra (70 KB) so the tracker can revisit them after its 80 ms delay. The DSP version should keep the last 1,200 input samples (2.4 KB) instead, and redo the FFT for the frame being decided. The FFT accelerator makes that cheap.
  4. Check against the float port with tools/check_c_port.py (PESQ within 0.05 and the same bits on most frames). Then measure cycles with TI’s free C55x compiler and its simulator, or on a C5517 evaluation board.

None of this needs the radio. Running it on the radio still depends on the DSP update path (workstream 3), which is open.