Conversation
…gine surface basic_real_fft gains a Scaling policy parameter (default scaling::fixed); the float/double primary template is unchanged apart from the template head and the static_asserts, and a constrained partial specialization routes std::int16_t / std::int32_t to detail::fixed_point_rdft in fft/fixed_point.h. Aliases real_fft_q15 / q31 / q15_bfp / q31_bfp. fft/tables.h is real: bit-reversal table, Q1.30 kernel twiddles, the real post-pass coefficient table, and the FNV-1a-64 checksum the battery pins. The transforms in fixed_point.h are TODO(3b) stubs that leave the data unchanged and return fixed_scaling_exponent(n), so the battery can be written against the final surface while the kernel lands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
…caling policies) Replaces the TODO(3b) stubs in fft/fixed_point.h with the engine: a radix-4 decimation-in-frequency complex kernel of length N/2 (radix-2 final stage for odd log2, radix-2^2 output placement so the permutation is a plain bit reversal), Ooura's rftfsub/rftbsub formulas and the rdft DC/Nyquist glue transcribed into fft_arith's operations, and one shift schedule serving both policies: scaling::fixed shifts each stage's growth before the butterfly (plus the profile's input pre-shift, folded into the first stage's shr_round); scaling::block_floating shifts min(max(0, growth + 1 - headroom), fixed_cumulative - e), so one bit of headroom is never consumed and e <= fixed_scaling_exponent(N) is a contract. Q15 widens into the work buffer on entry and narrows on exit, with one more growth-1 stage before the narrow under block floating point so the result fits Q0.15. Verified against basic_real_fft<double> scaled by 2^-e (scratch, not committed): forward, unnormalized inverse and round trip for N = 4 .. 65536 on full-scale noise, impulse, DC (both signs), the alternating pattern, on-bin cosine and sine, and the packed-pair patterns at full scale under 0/45-degree twiddles in both directions; no saturation under either policy. Floors: Q15 fixed 0.29 LSB rms (the narrow's rounding), Q31 fixed 0.69-0.73 LSB rms at every N and level (Welch variance model 0.55 plus the round-half-up bias); numbers go into the header and docs/fft-fixed-point.md next. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
…DME and CLAUDE.md fft/fixed_point.h and fft.h carry the measured floors as contract numbers (Q15 fixed 0.29 LSB rms; Q31 fixed 0.65-0.83 LSB rms, level-independent; block floating point per level), the round-half-up bias as an honest limit (coherent at DC, ~140 LSB at N = 65536 under Q31 BFP), and the CMSIS-DSP convention decision (Q15 fixed forward matches CMSIS's documented X / N; Q31 is one bit below; the inverse is Ooura's over 2^e; BFP has no analogue). docs/fft-fixed-point.md is the Stage 3b design note: kernel structure, the halving count and exponent derivation, the headroom proof for both policies, twiddle quantization, the Welch variance model beside the measured table, and the D3 decision. It folds into docs/fft-design.md at the wave-3 merge. README: the FFT profiles table (double / float / Q15 / Q31 with scale, floor and target) per Part 8 item 3; the fft_arith paragraph now points at the kernel and tables. CLAUDE.md: the four-profile ladder line and the checklist line per Part 8 item 5. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
|
The Stage 3b battery is up as #29, stacked on this branch (base 1. The header docstrings cite test names that are not the battery's.
2. Worst-case numbers in Table checksums on this host (x86-64 Linux, glibc 2.39; g++ and clang++ agree), for the record: N = 256 twiddles Generated by Claude Code |
The contract bullets named the tests before the battery existed; the names now match tests/test_fft_fixed.cpp, test_fft.cpp and test_fft_rt.cpp on claude/wave2-stage3b-battery, which passes 397/397 against this kernel on g++ 13 (merged locally, not pushed here). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
What this changes
Stage 3b of
docs/audit-fft-and-code-smells.md(Part 7 design, Decisions D2/D3): the fixed-point real FFT.basic_real_fftgains aScalingpolicy parameter (defaultscaling::fixed);std::int16_t(Q15) andstd::int32_t(Q31) route through a constrained partial specialization todetail::fixed_point_rdftinfft/fixed_point.h: one int32 radix-4 DIF kernel of length N/2 with Q1.30 twiddles, Ooura's real post-pass formulas and DC/Nyquist glue transcribed into thefft_arithtrait's operations, written entirely against the trait 3a landed (no arithmetic of its own). Aliasesreal_fft_q15,real_fft_q31,real_fft_q15_bfp,real_fft_q31_bfp.fft/tables.hcarries the bit-reversal, twiddle and real-post-pass generators (each coefficient one rounding from its double) and the FNV-1a-64 checksum the battery pins.docs/fft-fixed-point.mdis the design note; README gains the four-profile table; CLAUDE.md the ladder line and the checklist line.Status: DRAFT until the battery PR #29 (
claude/wave2-stage3b-battery, stacked on this branch) is merged into this branch. The kernel and its docs are complete; the battery passes 397/397 against this head (merged locally on g++ 13 and clang++ 18, see Verification).Why
Fixed-point projects (M4/M33-class, no affordable float) need an FFT with the same packing and
exp(+i)convention as the floating profiles, with the Q ladder visible and pinned rather than hidden in a wrapper class.The exponent contract (as implemented)
Every fixed-point transform returns
e. Read the buffer as fractions of full scale (Q0.15 / Q0.31); letGbebasic_real_fft<double>on the same input read as fractions. Forward:G.forward_inplaceresult== data * 2^e, same packing. Inverse:G's unnormalizedinverse_inplaceresult== data * 2^e. The fixed-pointinverse()applies no 2/N. Round trip:x == out * 2^(e_fwd + e_inv + 1 - log2 N)under both policies.scaling::fixed:e == fixed_scaling_exponent(N) == log2 N + fft_arith<Sample>::k_fixed_scaling_input_pre_shiftin both directions (Q15:log2 N, output exactlyX / N; Q31:log2 N + 1,X / 2N). Shift-before-butterfly: 2 bits per radix-4 stage, 1 per radix-2, 1 before the real post-pass / pre-pass, the Q31 pre-shift folded into the first shift as oneshr_round.scaling::block_floating:0 <= e <= fixed_scaling_exponent(N), data-dependent. Before each stage (and before the Q15 narrow) the shift isclamp(growth + 1 - headroom_bits, 0, fixed_cumulative - e): one bit of headroom is never consumed (the trait'sINT32_MINrule), and the upper clamp makes the bound a contract; it binds only when the block is already at the fixed schedule's data, where the result is the fixed schedule's bit for bit (pinned byBfpAtTheFullExponentIsBitIdenticalToFixed). An all-zero block returnse == 0.CMSIS-DSP convention (D3, decided and written in
fft.hand the design note)CMSIS documents
arm_rfft_q15/q31as "downscaled by 2 for every stage" (forward output X / N, log2 N bits to upscale). The Q15 fixed forward here is that same X / N; the Q31 fixed forward is X / 2N (the pre-shift, one bit below CMSIS); the inverse is Ooura's unnormalized inverse over 2^e ((1/N) Σ X W^-jk divided by 2 for Q15, 4 for Q31), not CMSIS's; block floating point has no CMSIS analogue. Only CMSIS's documentation was read.Measured floors vs Welch (output-referred, against the double golden model on the same quantized input; white noise; the level does not move the fixed floors)
The Q31 fixed gap to the variance-only model is the round-half-up bias of
shr_round(+2^-(s+1) LSB per s-bit shift): +0.22–0.28 LSB mean over the interior bins (the final one-bit shift) and ~20% extra variance from earlier stages' biases arriving with rotated phases. Honest limit, documented in the header: the bias is coherent along the unrotated DC path; +1.0–2.1 LSB at DC under Q31 fixed, ~15 LSB (N = 2048) to ~140 LSB (N = 65536, −143 dBFS) under Q31 BFP. Convergent rounding would remove it and is a different trait contract, not this kernel's decision. Part 7's "about 25 dB at −40 dBFS for N = 512" for Q15 fixed measures 25.9 dB.Verification
8b86d48) against this kernel: merged locally into a scratch worktree (git merge --no-commit, not pushed); full ctest 397/397 pass on g++ 13 and on clang++ 18 with-DTAP_DSP_WERROR=ON -DTAP_DSP_BUILD_CAPI=ON -DTAP_DSP_BUILD_BENCH=ON, including the fixed-point suites (fft_fixed_point_test,fft_fixed_scaling_test,fft_fixed_bfp_test,fft_fixed_tables,fft_fixed_q31,fft_fixed_profiles), the widenedreal_fft_test/fft_oracle_test/fft_rt_testover all four sample types, andQ15TracksDouble/Q31TracksDouble. No kernel change was needed. The QEMU legs run it once Stage 3b battery: the fixed-point contract, pinned #29 is merged into this branch.scripts/tidy.shclean on the TUs that includefft.h; pre-commit clang-format. CI on the head: all check runs green (3 hosts, 4 QEMU legs, style, drift, icount).basic_real_fft<double>scaled by 2^-e, forward, unnormalized inverse and round trip for N = 4 … 65536 on full-scale noise, impulse, DC (both signs,INT16_MINincluded), the alternating pattern, on-bin cosine and sine, and the packed-pair patterns at full scale on the 0° and 45° twiddle bins in both directions; no saturating operation under either policy (max error 0.5 LSB Q15, 2.3 / 5.6 LSB Q31 forward / inverse, same as on noise). Level sweep 0 / −20 / −40 / −60 dBFS at N = 256 / 512 / 2048, white noise and on-bin tone, all four profiles.Notes for the reviewer
rfft_q15_512/rfft_q31_512/rfft_q31_2048ratchet scenarios (bench/is the port agent's this wave) and foldingdocs/fft-fixed-point.mdintodocs/fft-design.md. Stage 3c: instruments, capi, notebook.🤖 Generated with Claude Code
https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy