Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,124 @@ tap_dsp_add_gtest_executable(tap_dsp_tests
target_link_libraries(tap_dsp_tests PRIVATE
tap::dsp
tap_dsp_warnings)

# ==============================================================================
# BEGIN Stage 2a additions (docs/audit-fft-and-code-smells.md, Part 9): the
# independent oracle and the real-time guard join the main executable; the
# Ooura bit-identity gate gets its own targets so it can own its compiler flags.
# Registration goes through tap_dsp_add_gtest_executable above, so the two
# targets are hosted (discovered) or bare-metal one-shot exactly like
# tap_dsp_tests. Keep this block self-contained and at the end of the file.
# ==============================================================================

# Largest transform the FFT sweeps run. Hosted, the default covers every
# consumer geometry (2^20: five 8 MB buffers plus tables). Cross-compiled, the
# emulated Cortex-M legs (Part 10) get 4096, what fits the MPS2/MPS3 data
# regions; the toolchain files set the same value in the cache themselves and
# a plain cache set here never overrides theirs or a -D on the command line.
# The parity sweep, its 2^20 test (compiled out below the cap: a GTEST_SKIP is
# rc=1 on the target) and the oracle's closed-form sweep all read it.
if(CMAKE_CROSSCOMPILING)
set(_tap_dsp_parity_max_n_default 4096)
else()
set(_tap_dsp_parity_max_n_default 1048576)
endif()
set(TAP_DSP_PARITY_MAX_N ${_tap_dsp_parity_max_n_default} CACHE STRING
"Largest FFT size the Ooura parity and oracle sweeps run (power of two >= 4; 4096 on emulated targets)")

target_sources(tap_dsp_tests PRIVATE
test_fft_oracle.cpp
test_fft_rt.cpp)
target_compile_definitions(tap_dsp_tests PRIVATE TAP_DSP_PARITY_MAX_N=${TAP_DSP_PARITY_MAX_N})

# ------------------------------------------------------------------------------
# Two private reference builds of the vendored C. Neither is the tap_dsp_fft
# the library links: that target disappears at Stage 2c (it survives only under
# TAP_DSP_FFT_CMSIS, where it also carries CMSIS objects), and the gate's whole
# point is to compare against the C independently of how the library builds it.
# The parity executables link one of these and NOT tap::dsp, so their
# rdft/rdft_f resolve here and no backend define (vDSP on Apple, CMSIS on the
# M55) reaches them.
#
# Stage 2c note: fftsg.c moves to tests/reference/ooura/ (D6); this gate needs
# fftsg_float.c to move with it, or its float side becomes port-vs-port.
#
# ..._nocontract fp-contraction OFF. Bit identity between the C and its C++
# transliteration holds only if both compile to the same
# sequence of IEEE operations, and CMake gives neither side an
# fp-contract setting: gcc contracts C in gnu17 mode, g++
# contracts C++ in every mode, clang contracts everywhere
# (Part 4, "fp-contraction policy"). MSVC: nothing is passed;
# its default /fp:precise has not contracted since VS 2022
# 17.0 made /fp:contract opt-in (earlier x64 /arch:AVX2 and
# ARM64 builds did contract under /fp:precise; windows-latest
# is well past 17.0).
# ..._default the same two files at default flags, for the informational
# target, so it measures "port at default flags vs C at
# default flags" and nothing else.
# ------------------------------------------------------------------------------
set(_tap_dsp_nocontract_c "$<$<C_COMPILER_ID:GNU,Clang,AppleClang>:-ffp-contract=off>")
set(_tap_dsp_nocontract_cxx "$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-ffp-contract=off>")
set(_tap_dsp_reference_sources
${PROJECT_SOURCE_DIR}/third_party/ooura/fftsg.c
${PROJECT_SOURCE_DIR}/third_party/ooura/fftsg_float.c)

add_library(tap_dsp_fft_reference_nocontract STATIC ${_tap_dsp_reference_sources})
target_compile_options(tap_dsp_fft_reference_nocontract PRIVATE ${_tap_dsp_nocontract_c})

add_library(tap_dsp_fft_reference_default STATIC ${_tap_dsp_reference_sources})

# THE GATE: -ffp-contract=off on both sides, memcmp bit identity. On the QEMU
# legs it runs whole (no MAIN_FILTER): at N <= 4096 the sweep is milliseconds.
tap_dsp_add_gtest_executable(tap_dsp_fft_parity
SOURCES test_fft_parity_ooura.cpp
LABELS parity)
target_include_directories(tap_dsp_fft_parity PRIVATE ${PROJECT_SOURCE_DIR}/include)
target_compile_features(tap_dsp_fft_parity PRIVATE cxx_std_20)
target_compile_options(tap_dsp_fft_parity PRIVATE ${_tap_dsp_nocontract_cxx})
target_compile_definitions(tap_dsp_fft_parity PRIVATE TAP_DSP_PARITY_MAX_N=${TAP_DSP_PARITY_MAX_N})
target_link_libraries(tap_dsp_fft_parity PRIVATE
tap_dsp_fft_reference_nocontract
tap_dsp_warnings)

# INFORMATIONAL: the same source at default flags on both sides, measuring the
# max-ulp deviation per N and never failing. It is not a pass/fail gate with an
# assumed bound because a different fusion choice per butterfly stage
# accumulates over log2 N stages; the number goes on the record per platform
# instead (Part 6, item N3). Two channels carry it, because ctest hides the
# stdout of a passing test under --output-on-failure: (1) CI runs the parity
# label with -V as its own step (ci.yml, Part 13); (2) the JUnit XML below.
# The report is ONE test covering both precisions so a single invocation
# produces the complete XML.
tap_dsp_add_gtest_executable(tap_dsp_fft_parity_default_flags
SOURCES test_fft_parity_ooura.cpp
LABELS parity)
target_include_directories(tap_dsp_fft_parity_default_flags PRIVATE ${PROJECT_SOURCE_DIR}/include)
target_compile_features(tap_dsp_fft_parity_default_flags PRIVATE cxx_std_20)
target_compile_definitions(tap_dsp_fft_parity_default_flags PRIVATE
TAP_DSP_PARITY_INFORMATIONAL
TAP_DSP_PARITY_MAX_N=${TAP_DSP_PARITY_MAX_N})
target_link_libraries(tap_dsp_fft_parity_default_flags PRIVATE
tap_dsp_fft_reference_default
tap_dsp_warnings)

# JUnit XML for the informational report (hosted only: bare metal has no file
# system). gtest reads GTEST_OUTPUT from the environment at start-up, and the
# helper exposes no per-test properties or extra arguments, so the ENVIRONMENT
# property is attached the way gtest_discover_tests itself attaches its
# registrations: a script on the directory's TEST_INCLUDE_FILES, which ctest
# runs after the discovery script that defines the test (if(TEST ...) is not
# available in that context, so the call is unconditional: a discovery failure
# then errors here as well as in its own _NOT_BUILT entry). Should the helper
# grow an EXTRA_ARGS/PROPERTIES pass-through, this collapses to one line there.
if(NOT TAP_DSP_BARE_METAL)
set(_tap_dsp_parity_xml_script ${CMAKE_CURRENT_BINARY_DIR}/tap_dsp_fft_parity_default_flags_env.cmake)
file(WRITE ${_tap_dsp_parity_xml_script}
"set_tests_properties(fft_parity_ooura_default_flags.ReportsMaxUlpVersusOoura PROPERTIES\n"
" ENVIRONMENT \"GTEST_OUTPUT=xml:${CMAKE_BINARY_DIR}/parity-ulp.xml\")\n")
set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES ${_tap_dsp_parity_xml_script})
endif()

# ==============================================================================
# END Stage 2a additions
# ==============================================================================
78 changes: 78 additions & 0 deletions tests/support/signals.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/// @file signals.h
/// @brief Shared test-signal generators for the DspTap test battery.
// SPDX-License-Identifier: MIT
// Copyright 2026 Timothy Place and the DspTap contributors.
//
// One xorshift32, one random_signal and one tone synthesizer, so a new test
// file does not grow its own copy (three already exist in test_fft.cpp,
// test_fft_backend.cpp and test_nn.cpp; migrating them, and adding the dB
// helper Part 9 lists once a caller exists, is the Stage 6 hygiene item in
// docs/audit-fft-and-code-smells.md, not this file's job). Everything here is deterministic — fixed seeds, no wall
// clock, no filesystem — so it can run unchanged on the bare-metal QEMU legs.

#pragma once

#include <cmath>
#include <cstddef>
#include <cstdint>
#include <numbers>
#include <vector>

namespace tap::dsp::test {

/// Marsaglia's xorshift32 (Journal of Statistical Software 8(14), 2003),
/// the generator every existing copy in this repo already uses. The state
/// must be non-zero; a zero seed is replaced by a fixed non-zero constant.
class xorshift32 {
public:
explicit xorshift32(std::uint32_t seed) noexcept
: m_s(seed != 0u ? seed : 0x9E3779B9u) {}

/// Next raw 32-bit state.
std::uint32_t next_u32() noexcept {
m_s ^= m_s << 13;
m_s ^= m_s >> 17;
m_s ^= m_s << 5;
return m_s;
}

/// Uniform in [-1, 1), computed in double so float and double signals
/// drawn from the same seed are the same values up to the final cast.
double next_unit() noexcept { return static_cast<double>(next_u32()) / 2147483648.0 - 1.0; }

private:
std::uint32_t m_s;
};

/// n samples uniform in [-amplitude, amplitude), from a fixed seed.
template <typename Sample>
std::vector<Sample> random_signal(std::size_t n, std::uint32_t seed, double amplitude = 1.0) {
xorshift32 rng(seed);
std::vector<Sample> x(n);
for (auto& v : x) {
v = static_cast<Sample>(amplitude * rng.next_unit());
}
return x;
}

/// amplitude * cos(2*pi*bin*j/n + phase) for j in [0, n).
///
/// The angle is formed as 2*pi * fmod(bin*j, n) / n, not as (2*pi*bin/n) * j:
/// with a rounded per-sample increment the phase error grows like j*ulp,
/// and at n = 65536 a supposedly on-bin cosine is ~1e-11 off, a thousand
/// times the engine's own error — which is what a closed-form oracle would
/// then wrongly report. fmod is exact, bin*j is exact for integer bins below
/// 2^53, and the one division by a power-of-two n is exact, so an integer-bin
/// tone here is the sampled cosine to one rounding of cos() per sample.
template <typename Sample>
std::vector<Sample> tone(std::size_t n, double bin, double amplitude, double phase = 0.0) {
std::vector<Sample> x(n);
const double period = static_cast<double>(n);
for (std::size_t j = 0; j < n; ++j) {
const double turns = std::fmod(bin * static_cast<double>(j), period) / period;
x[j] = static_cast<Sample>(amplitude * std::cos(2.0 * std::numbers::pi * turns + phase));
}
return x;
}

} // namespace tap::dsp::test
Loading
Loading