diff --git a/blocks/CMakeLists.txt b/blocks/CMakeLists.txt
index 1070f919b..99f45234d 100644
--- a/blocks/CMakeLists.txt
+++ b/blocks/CMakeLists.txt
@@ -7,6 +7,7 @@ add_subdirectory(http)
add_subdirectory(math)
add_subdirectory(soapy)
add_subdirectory(testing)
+add_subdirectory(analog)
# shared library that contains all registered blocks
add_subdirectory(libs)
diff --git a/blocks/analog/CMakeLists.txt b/blocks/analog/CMakeLists.txt
new file mode 100644
index 000000000..5e52249a5
--- /dev/null
+++ b/blocks/analog/CMakeLists.txt
@@ -0,0 +1,28 @@
+set(GrAnalogBlocks_HDRS
+ include/gnuradio-4.0/analog/Agc.hpp
+ include/gnuradio-4.0/analog/Agc2.hpp
+ include/gnuradio-4.0/analog/AmDemod.hpp
+ include/gnuradio-4.0/analog/FmDet.hpp
+ include/gnuradio-4.0/analog/FrequencyMod.hpp
+ include/gnuradio-4.0/analog/PhaseModulator.hpp
+ include/gnuradio-4.0/analog/QuadratureDemod.hpp)
+
+set(GrAnalogBlocks_LIBS gr-analog)
+
+add_library(gr-analog INTERFACE ${GrAnalogBlocks_HDRS})
+target_link_libraries(gr-analog INTERFACE gnuradio-core gnuradio-algorithm)
+target_include_directories(gr-analog INTERFACE $
+ $)
+
+gr_add_block_library(
+ GrAnalogBlocks
+ MAKE_SHARED_LIBRARY
+ MAKE_STATIC_LIBRARY
+ HEADERS
+ ${GrAnalogBlocks_HDRS}
+ LINK_LIBRARIES
+ ${GrAnalogBlocks_LIBS})
+
+if(TARGET GrAnalogBlocksShared AND ENABLE_TESTING)
+ add_subdirectory(test)
+endif()
diff --git a/blocks/analog/include/gnuradio-4.0/analog/Agc.hpp b/blocks/analog/include/gnuradio-4.0/analog/Agc.hpp
new file mode 100644
index 000000000..ab7e308d0
--- /dev/null
+++ b/blocks/analog/include/gnuradio-4.0/analog/Agc.hpp
@@ -0,0 +1,57 @@
+#ifndef INCLUDED_ANALOG_AGC_HPP
+#define INCLUDED_ANALOG_AGC_HPP
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace gr::blocks::analog {
+
+template>
+struct Agc : public Block>
+{
+ PortIn in;
+ PortOut out;
+
+ Annotated rate = 1.0e-4f;
+ Annotated ref = 1.0f;
+ Annotated gain = 1.0f;
+ Annotated gmax = 0.0f; // 0 ⇒ unlimited
+
+ GR_MAKE_REFLECTABLE(Agc, in, out, rate, ref, gain, gmax);
+
+ template
+ work::Status processBulk(const InSpan& xs, OutSpan& ys)
+ {
+ const std::size_t n = std::min(xs.size(), ys.size());
+ float g = gain;
+ const float r = rate, R = ref, M = gmax;
+
+ for (std::size_t i = 0; i < n; ++i) {
+ const auto x = xs[i];
+ const auto y = static_cast(x * g); // apply current gain
+
+ const float amp = std::abs(y); // magnitude of *output*
+ g += (R - amp) * r; // adapt afterwards
+ if (M > 0.f && g > M) g = M;
+
+ ys[i] = y;
+ }
+ gain = g;
+ ys.publish(n);
+ return work::Status::OK;
+ }
+};
+
+using AgcCC = Agc, false>;
+using AgcFF = Agc;
+
+GR_REGISTER_BLOCK("gr::blocks::analog::AgcCC", gr::blocks::analog::AgcCC)
+GR_REGISTER_BLOCK("gr::blocks::analog::AgcFF", gr::blocks::analog::AgcFF)
+
+} // namespace gr::blocks::analog
+#endif /* INCLUDED_ANALOG_AGC_HPP */
diff --git a/blocks/analog/include/gnuradio-4.0/analog/Agc2.hpp b/blocks/analog/include/gnuradio-4.0/analog/Agc2.hpp
new file mode 100644
index 000000000..cb887ded9
--- /dev/null
+++ b/blocks/analog/include/gnuradio-4.0/analog/Agc2.hpp
@@ -0,0 +1,67 @@
+#ifndef INCLUDED_ANALOG_AGC2_HPP
+#define INCLUDED_ANALOG_AGC2_HPP
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace gr::blocks::analog {
+
+template>
+struct Agc2 : public Block>
+{
+ PortIn in;
+ PortOut out;
+
+ Annotated attack_rate = 1.0e-1f;
+ Annotated decay_rate = 1.0e-2f;
+ Annotated ref = 1.0f;
+ Annotated gain = 1.0f;
+ Annotated gmax = 0.0f; // 0 ⇒ unlimited
+
+ GR_MAKE_REFLECTABLE(Agc2, in, out,
+ attack_rate, decay_rate,
+ ref, gain, gmax);
+
+ template
+ work::Status processBulk(const InSpan& xs, OutSpan& ys)
+ {
+ const std::size_t N = std::min(xs.size(), ys.size());
+ float g = gain;
+ const float R = ref,
+ A = attack_rate,
+ D = decay_rate,
+ M = gmax;
+
+ for (std::size_t i = 0; i < N; ++i) {
+ const auto x = xs[i];
+ const auto y = static_cast(x * g); // apply current gain
+
+ const float amp = std::abs(y); // magnitude of output
+ const float rate = (std::abs(amp - R) > g) ? A : D; // attack vs decay
+ g -= (amp - R) * rate;
+
+ if (g < 1.0e-5f) g = 1.0e-5f; // avoid blow‑ups
+ if (M > 0.f && g > M) g = M;
+
+ ys[i] = y;
+ }
+ gain = g;
+ ys.publish(N);
+ return work::Status::OK;
+ }
+};
+
+using Agc2CC = Agc2, false>;
+using Agc2FF = Agc2;
+
+GR_REGISTER_BLOCK("gr::blocks::analog::Agc2CC", gr::blocks::analog::Agc2CC)
+GR_REGISTER_BLOCK("gr::blocks::analog::Agc2FF", gr::blocks::analog::Agc2FF)
+
+} // namespace gr::blocks::analog
+#endif /* INCLUDED_ANALOG_AGC2_HPP */
diff --git a/blocks/analog/include/gnuradio-4.0/analog/AmDemod.hpp b/blocks/analog/include/gnuradio-4.0/analog/AmDemod.hpp
new file mode 100644
index 000000000..0d29e12c1
--- /dev/null
+++ b/blocks/analog/include/gnuradio-4.0/analog/AmDemod.hpp
@@ -0,0 +1,86 @@
+#ifndef INCLUDED_ANALOG_AM_DEMOD_HPP
+#define INCLUDED_ANALOG_AM_DEMOD_HPP
+
+#include
+#include
+#include
+
+#include
+#include
+
+namespace gr::blocks::analog
+{
+struct AmDemod : gr::Block // fixed‑rate block (1→1)
+{
+ using Description = Doc<
+ R""(@brief Envelope AM demodulator (complex → float))"">;
+
+ PortIn> in;
+ PortOut out;
+
+ Annotated>
+ chan_rate{48'000.f};
+ Annotated>
+ audio_decim{8};
+ Annotated>
+ audio_pass{4'000.f};
+ Annotated>
+ audio_stop{5'500.f};
+
+ GR_MAKE_REFLECTABLE(
+ AmDemod, in, out, chan_rate, audio_decim, audio_pass, audio_stop);
+
+ void set_chan_rate (float fs) { chan_rate = fs; _recalc(); }
+ void set_audio_decim(int d ) { audio_decim = std::max(1, d); _recalc(); }
+ void set_audio_pass (float fp){ audio_pass = fp; _recalc(); }
+
+ explicit AmDemod(property_map) { _recalc(); }
+ AmDemod(float fs, int d, float fp, float fsb = 0.f)
+ : chan_rate(fs), audio_decim(std::max(1, d)),
+ audio_pass(fp), audio_stop(fsb)
+ { _recalc(); }
+
+ void settingsChanged(const property_map&, const property_map&)
+ { _recalc(); }
+
+ template
+ [[nodiscard]] work::Status
+ processBulk(const InSpan& xs, OutSpan& ys)
+ {
+ std::size_t produced = 0;
+
+ for (auto x : xs) {
+ const float env = std::abs(x);
+ _y = env + _alpha * (_y - env);
+
+ if (produced == ys.size()) {
+ ys.publish(produced);
+ return work::Status::INSUFFICIENT_OUTPUT_ITEMS;
+ }
+
+ ys[produced++] = _y; // 1 : 1 output
+ }
+
+ ys.publish(produced);
+ return work::Status::OK;
+ }
+
+private:
+ float _alpha{1.f}; // IIR coefficient
+ float _y{0.f}; // filter state
+
+ void _recalc()
+ {
+ /* one‑pole IIR coefficient (designed at the INPUT rate) */
+ const float dt = 1.0f / chan_rate;
+ _alpha = std::exp(-2.f * std::numbers::pi_v
+ * audio_pass * dt);
+ }
+};
+
+GR_REGISTER_BLOCK("gr::blocks::analog::AmDemod",
+ gr::blocks::analog::AmDemod)
+
+} // namespace gr::blocks::analog
+#endif /* INCLUDED_ANALOG_AM_DEMOD_HPP */
diff --git a/blocks/analog/include/gnuradio-4.0/analog/FmDet.hpp b/blocks/analog/include/gnuradio-4.0/analog/FmDet.hpp
new file mode 100644
index 000000000..a79891ff3
--- /dev/null
+++ b/blocks/analog/include/gnuradio-4.0/analog/FmDet.hpp
@@ -0,0 +1,68 @@
+#ifndef GNURADIO_ANALOG_FMDET_HPP
+#define GNURADIO_ANALOG_FMDET_HPP
+
+#include
+#include
+#include
+#include
+
+namespace gr::blocks::analog {
+
+template struct FmDet;
+
+template<>
+struct FmDet> : Block>>
+{
+ using Description = Doc<"IQ slope detector (complex → float)">;
+
+ PortIn> in;
+ PortOut out;
+
+ Annotated samplerate = 1.0f;
+ Annotated f_low = -1.0f;
+ Annotated f_high = 1.0f;
+ Annotated scl = 1.0f;
+ GR_MAKE_REFLECTABLE(FmDet,in,out,samplerate,f_low,f_high,scl);
+
+ std::complex _prev = {1.0f, 0.0f}; // initial phase = 0
+ float _bias = 0.0f;
+
+ void recompute_bias()
+ {
+ const float hi = f_high, lo = f_low;
+ _bias = (hi != lo) ? 0.5f * scl * (hi + lo) / (hi - lo) : 0.0f;
+ }
+
+ void set_scale(float s) { scl = s; recompute_bias(); }
+ float scale() const { return scl; }
+
+ void set_freq_range(float lo,float hi) { f_low = lo; f_high = hi; recompute_bias(); }
+ float freq_low() const { return f_low; }
+ float freq_high() const { return f_high; }
+ float freq() const { return 0.0f; } // legacy stub
+ float bias() const { return _bias; }
+
+ void start() { _prev = {1.0f,0.0f}; recompute_bias(); }
+
+ work::Status processOne(const std::complex& x, float& y)
+ {
+ const std::complex prod = x * std::conj(_prev);
+ _prev = x;
+ y = scl * std::arg(prod) - _bias;
+ return work::Status::OK;
+ }
+
+ template
+ work::Status processBulk(const InSpan& xs, OutSpan& ys)
+ {
+ const std::size_t n = std::min(xs.size(), ys.size());
+ for(std::size_t i = 0; i < n; ++i) processOne(xs[i], ys[i]);
+ ys.publish(n);
+ return work::Status::OK;
+ }
+};
+
+GR_REGISTER_BLOCK("gr::blocks::analog::FmDet",gr::blocks::analog::FmDet,([T]),[ std::complex ])
+
+} // namespace gr::blocks::analog
+#endif /* GNURADIO_ANALOG_FMDET_HPP */
diff --git a/blocks/analog/include/gnuradio-4.0/analog/FrequencyMod.hpp b/blocks/analog/include/gnuradio-4.0/analog/FrequencyMod.hpp
new file mode 100644
index 000000000..d6208bf07
--- /dev/null
+++ b/blocks/analog/include/gnuradio-4.0/analog/FrequencyMod.hpp
@@ -0,0 +1,70 @@
+#ifndef GNURADIO_ANALOG_FREQUENCYMOD_HPP
+#define GNURADIO_ANALOG_FREQUENCYMOD_HPP
+
+#include
+#include
+
+#include
+#include
+
+namespace gr::blocks::analog {
+
+template
+struct FrequencyMod;
+
+template<>
+struct FrequencyMod : Block>
+{
+ using Description = Doc<"Frequency‑modulator (float → complex)">;
+
+ PortIn in;
+ PortOut> out;
+
+ Annotated>
+ sensitivity = std::numbers::pi_v / 4.0f; // π/4 rad/sample
+
+ GR_MAKE_REFLECTABLE(FrequencyMod, in, out, sensitivity);
+
+ float _phase = 0.0f;
+ void start() { _phase = 0.0f; }
+
+ inline std::complex to_polar(float ph) const
+ {
+ return { std::cos(ph), std::sin(ph) };
+ }
+
+ work::Status processOne(float x, std::complex& y)
+ {
+ _phase += x * sensitivity;
+ /* keep phase in [-π, π] to avoid float overflow */
+ constexpr float two_pi = 2.0f * std::numbers::pi_v;
+ if (_phase > std::numbers::pi_v) _phase -= two_pi;
+ if (_phase < -std::numbers::pi_v) _phase += two_pi;
+
+ y = to_polar(_phase);
+ return work::Status::OK;
+ }
+
+ template
+ work::Status processBulk(const InSpan& xs, OutSpan& ys)
+ {
+ if (xs.empty())
+ return work::Status::DONE;
+
+ const std::size_t n = std::min(xs.size(), ys.size());
+ for (std::size_t i = 0; i < n; ++i)
+ processOne(xs[i], ys[i]);
+
+ ys.publish(n);
+ return work::Status::OK;
+ }
+};
+
+GR_REGISTER_BLOCK("gr::blocks::analog::FrequencyMod",gr::blocks::analog::FrequencyMod,([T]),[ float ])
+
+} // namespace gr::blocks::analog
+#endif /* GNURADIO_ANALOG_FREQUENCYMOD_HPP */
diff --git a/blocks/analog/include/gnuradio-4.0/analog/PhaseModulator.hpp b/blocks/analog/include/gnuradio-4.0/analog/PhaseModulator.hpp
new file mode 100644
index 000000000..e0771f30f
--- /dev/null
+++ b/blocks/analog/include/gnuradio-4.0/analog/PhaseModulator.hpp
@@ -0,0 +1,51 @@
+#ifndef INCLUDED_ANALOG_PHASE_MODULATOR_HPP
+#define INCLUDED_ANALOG_PHASE_MODULATOR_HPP
+
+#include
+#include
+
+#include
+#include
+
+namespace gr::blocks::analog {
+
+struct PhaseModulator
+ : gr::Block> // 1 : 1
+{
+ using Description = Doc;
+
+ PortIn in;
+ PortOut> out;
+
+ Annotated> sensitivity{1.0f};
+
+ GR_MAKE_REFLECTABLE(PhaseModulator, in, out, sensitivity);
+
+ explicit PhaseModulator(gr::property_map) {}
+
+ void set_sensitivity(float s) { sensitivity = s; }
+
+ void settingsChanged(const property_map&, const property_map&)
+ { /* nothing else to recompute */ }
+
+ template
+ [[nodiscard]] work::Status
+ processBulk(const InSpan& xs, OutSpan& ys)
+ {
+ const std::size_t n = std::min(xs.size(), ys.size());
+ for (std::size_t i = 0; i < n; ++i) {
+ const float phi = sensitivity * xs[i];
+ ys[i] = { std::cos(phi), std::sin(phi) };
+ }
+ ys.publish(n);
+ return work::Status::OK;
+ }
+};
+
+GR_REGISTER_BLOCK("gr::blocks::analog::PhaseModulator",
+ gr::blocks::analog::PhaseModulator)
+
+} // namespace gr::blocks::analog
+#endif /* INCLUDED_ANALOG_PHASE_MODULATOR_HPP */
diff --git a/blocks/analog/include/gnuradio-4.0/analog/QuadratureDemod.hpp b/blocks/analog/include/gnuradio-4.0/analog/QuadratureDemod.hpp
new file mode 100644
index 000000000..8c954ead2
--- /dev/null
+++ b/blocks/analog/include/gnuradio-4.0/analog/QuadratureDemod.hpp
@@ -0,0 +1,59 @@
+#ifndef GNURADIO_ANALOG_QUADRATUREDEMOD_HPP
+#define GNURADIO_ANALOG_QUADRATUREDEMOD_HPP
+#include
+#include
+#include
+#include
+
+namespace gr::blocks::analog {
+
+template struct QuadratureDemod;
+
+template<>
+struct QuadratureDemod>
+ : Block>>
+{
+ using Description =
+ Doc<"Quadrature FM demodulator (complex → float)">;
+
+ PortIn> in;
+ PortOut out;
+
+ Annotated>
+ gain = 1.0f;
+
+ GR_MAKE_REFLECTABLE(QuadratureDemod,in,out,gain);
+
+ std::complex _prev{1.0f,0.0f};
+ bool _have_prev{false};
+
+ void start() { _have_prev = false; _prev = {1.0f,0.0f}; }
+
+ work::Status processOne(std::complex x, float& y)
+ {
+ if(!_have_prev){ // first sample ⇒ y = 0
+ y = 0.0f;
+ _prev = x;
+ _have_prev = true;
+ } else {
+ y = gain * std::arg(x * std::conj(_prev));
+ _prev = x;
+ }
+ return work::Status::OK;
+ }
+
+ template
+ work::Status processBulk(const InSpan& xs, OutSpan& ys)
+ {
+ const std::size_t n = std::min(xs.size(), ys.size());
+ for(std::size_t i=0;i ])
+
+} // namespace gr::blocks::analog
+#endif /* GNURADIO_ANALOG_QUADRATUREDEMOD_HPP */
diff --git a/blocks/analog/test/CMakeLists.txt b/blocks/analog/test/CMakeLists.txt
new file mode 100644
index 000000000..b32c069c0
--- /dev/null
+++ b/blocks/analog/test/CMakeLists.txt
@@ -0,0 +1,27 @@
+add_ut_test(qa_Agc)
+target_link_libraries(qa_Agc PRIVATE gr-analog)
+target_compile_options(qa_Agc PRIVATE -Wno-conversion -Wno-error=conversion)
+
+add_ut_test(qa_Agc2)
+target_link_libraries(qa_Agc2 PRIVATE gr-analog)
+target_compile_options(qa_Agc2 PRIVATE -Wno-conversion -Wno-error=conversion)
+
+add_ut_test(qa_AmDemod)
+target_link_libraries(qa_AmDemod PRIVATE gr-analog)
+target_compile_options(qa_AmDemod PRIVATE -Wno-conversion -Wno-error=conversion)
+
+add_ut_test(qa_FmDet)
+target_link_libraries(qa_FmDet PRIVATE gr-analog)
+target_compile_options(qa_FmDet PRIVATE -Wno-conversion -Wno-error=conversion)
+
+add_ut_test(qa_FrequencyMod)
+target_link_libraries(qa_FrequencyMod PRIVATE gr-analog)
+target_compile_options(qa_FrequencyMod PRIVATE -Wno-conversion -Wno-error=conversion)
+
+add_ut_test(qa_PhaseModulator)
+target_link_libraries(qa_PhaseModulator PRIVATE gr-analog)
+target_compile_options(qa_PhaseModulator PRIVATE -Wno-conversion -Wno-error=conversion)
+
+add_ut_test(qa_QuadratureDemod)
+target_link_libraries(qa_QuadratureDemod PRIVATE gr-analog)
+target_compile_options(qa_QuadratureDemod PRIVATE -Wno-conversion -Wno-error=conversion)
\ No newline at end of file
diff --git a/blocks/analog/test/qa_Agc.cpp b/blocks/analog/test/qa_Agc.cpp
new file mode 100644
index 000000000..20127fa90
--- /dev/null
+++ b/blocks/analog/test/qa_Agc.cpp
@@ -0,0 +1,91 @@
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+using namespace gr;
+using namespace gr::testing;
+using namespace gr::blocks::analog;
+using namespace boost::ut;
+
+// Explicit type aliases for CI compatibility
+using AgcCC = gr::blocks::analog::Agc, false>;
+using AgcFF = gr::blocks::analog::Agc;
+
+template
+std::vector run(const std::vector& drive,
+ float rate = 2.0e-2f)
+{
+ Graph g;
+
+ auto& src = g.emplaceBlock>();
+ src.values = drive;
+
+ auto& agc = g.emplaceBlock();
+ agc.rate = rate;
+
+ auto& sink =
+ g.emplaceBlock>();
+
+ [[maybe_unused]] auto c1 = g.connect<"out">(src).template to<"in">(agc);
+ [[maybe_unused]] auto c2 = g.connect<"out">(agc).template to<"in">(sink);
+
+ scheduler::Simple sch{ std::move(g) };
+ expect(bool{ sch.runAndWait() });
+
+ return sink._samples;
+}
+
+suite agc = [] {
+
+ "AgcCC tracks magnitude"_test = [] {
+ constexpr std::size_t N = 2'048;
+ constexpr std::size_t skip = 1'512; // leave 536 samples for eval
+
+ std::vector> drive;
+ drive.reserve(N);
+ for (std::size_t n = 0; n < N; ++n) {
+ const float phi = 2.f * std::numbers::pi_v *
+ static_cast(n) / static_cast(N);
+ drive.emplace_back(30.f * std::cos(phi), 30.f * std::sin(phi));
+ }
+
+ auto y = run, AgcCC>(drive);
+
+ float err = 0.f;
+ for (std::size_t i = skip; i < y.size(); ++i)
+ err += std::fabs(std::abs(y[i]) - 1.f);
+ err /= static_cast(y.size() - skip); // mean |error|
+
+ expect(err < 0.05f);
+ };
+
+ "AgcFF tracks magnitude"_test = [] {
+ constexpr std::size_t N = 2'048;
+ constexpr std::size_t skip = 1'512;
+
+ std::vector drive;
+ drive.reserve(N);
+ for (std::size_t n = 0; n < N; ++n) {
+ const float phi = 2.f * std::numbers::pi_v *
+ static_cast(n) / static_cast(N);
+ drive.emplace_back(50.f * std::sin(phi));
+ }
+
+ auto y = run(drive);
+
+ float err = 0.f;
+ for (std::size_t i = skip; i < y.size(); ++i)
+ err += std::fabs(std::fabs(y[i]) - 1.f);
+ err /= static_cast(y.size() - skip);
+
+ expect(err < 0.05f);
+ };
+};
+
+int main() {} // Boost.UT auto-runs suites
diff --git a/blocks/analog/test/qa_Agc2.cpp b/blocks/analog/test/qa_Agc2.cpp
new file mode 100644
index 000000000..c0bb26a10
--- /dev/null
+++ b/blocks/analog/test/qa_Agc2.cpp
@@ -0,0 +1,94 @@
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+using namespace gr;
+using namespace gr::testing;
+using namespace gr::blocks::analog;
+using namespace boost::ut;
+
+// Explicit type aliases for CI compatibility
+using Agc2CC = gr::blocks::analog::Agc2, false>;
+using Agc2FF = gr::blocks::analog::Agc2;
+
+template
+std::vector run(const std::vector& drive,
+ float a_rate = 1.0e-1f,
+ float d_rate = 1.0e-2f)
+{
+ Graph g;
+
+ auto& src = g.emplaceBlock>();
+ src.values = drive;
+
+ auto& agc2 = g.emplaceBlock();
+ agc2.attack_rate = a_rate;
+ agc2.decay_rate = d_rate;
+
+ auto& sink =
+ g.emplaceBlock>();
+
+ (void)g.connect<"out">(src).template to<"in">(agc2);
+ (void)g.connect<"out">(agc2).template to<"in">(sink);
+
+ scheduler::Simple sch{ std::move(g) };
+ expect(bool{ sch.runAndWait() });
+
+ return sink._samples;
+}
+
+suite agc2 = [] {
+
+ "Agc2CC converges"_test = [] {
+ constexpr std::size_t N = 3072;
+ constexpr std::size_t skip = 2048;
+
+ std::vector> drive;
+ drive.reserve(N);
+ for (std::size_t n = 0; n < N; ++n) {
+ const float phi = 2.f * std::numbers::pi_v *
+ static_cast(n) / static_cast(N);
+ drive.emplace_back(40.f * std::sin(phi),
+ 40.f * std::cos(phi));
+ }
+
+ auto y = run, Agc2CC>(drive);
+
+ float err = 0.f;
+ for (std::size_t i = skip; i < y.size(); ++i)
+ err += std::fabs(std::abs(y[i]) - 1.f);
+ err /= static_cast(y.size() - skip);
+
+ expect(err < 0.05f);
+ };
+
+ "Agc2FF converges"_test = [] {
+ constexpr std::size_t N = 3072;
+ constexpr std::size_t skip = 2048;
+
+ std::vector drive;
+ drive.reserve(N);
+ for (std::size_t n = 0; n < N; ++n) {
+ const float phi = 2.f * std::numbers::pi_v *
+ static_cast(n) / static_cast(N);
+ drive.emplace_back(60.f * std::cos(phi));
+ }
+
+ auto y = run(drive);
+
+ float err = 0.f;
+ for (std::size_t i = skip; i < y.size(); ++i)
+ err += std::fabs(std::fabs(y[i]) - 1.f);
+ err /= static_cast(y.size() - skip);
+
+ expect(err < 0.05f);
+ };
+};
+
+int main() {} // Boost.UT auto‑runs suites
diff --git a/blocks/analog/test/qa_AmDemod.cpp b/blocks/analog/test/qa_AmDemod.cpp
new file mode 100644
index 000000000..e90d7275e
--- /dev/null
+++ b/blocks/analog/test/qa_AmDemod.cpp
@@ -0,0 +1,72 @@
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+using namespace gr;
+using namespace gr::testing;
+using namespace gr::blocks::analog;
+using namespace boost::ut;
+
+template
+std::vector run(const std::vector& drive,
+ float fs, int decim, float f_pass)
+{
+ Graph g;
+
+ auto& src = g.emplaceBlock>();
+ src.values = drive;
+
+ auto& dem = g.emplaceBlock();
+ dem.set_chan_rate(fs);
+ dem.set_audio_decim(decim);
+ dem.set_audio_pass(f_pass);
+
+ auto& sink =
+ g.emplaceBlock>();
+
+ [[maybe_unused]] auto c1 =
+ g.connect<"out">(src).template to<"in">(dem);
+ [[maybe_unused]] auto c2 =
+ g.connect<"out">(dem).template to<"in">(sink);
+
+ scheduler::Simple sch{std::move(g)};
+ expect(bool{sch.runAndWait()});
+
+ return sink._samples;
+}
+
+suite am_demod = [] {
+ "constant_envelope"_test = [] {
+ constexpr float fs = 48'000.f;
+ constexpr int dec = 8;
+ constexpr float f_lp = 4'000.f;
+ constexpr std::size_t N = 4 * 48'000; // 4 s
+
+ std::vector> drive;
+ drive.reserve(N);
+ for (std::size_t n = 0; n < N; ++n) {
+ const float phi = 2.f * std::numbers::pi_v *
+ 1'000.f * static_cast(n) / fs;
+ drive.emplace_back(0.7f * std::cos(phi),
+ 0.7f * std::sin(phi));
+ }
+
+ auto y = run, float>(drive, fs, dec, f_lp);
+
+ const std::size_t skip = 200; // allow IIR to settle
+ float mean = 0.f;
+ for (std::size_t i = skip; i < y.size(); ++i)
+ mean += y[i];
+ mean /= static_cast(y.size() - skip);
+
+ expect(std::abs(mean - 0.7f) < 0.005f); // ≤ 0.5 % error
+ };
+};
+
+int main() {} // Boost.UT auto‑runs suites
diff --git a/blocks/analog/test/qa_FmDet.cpp b/blocks/analog/test/qa_FmDet.cpp
new file mode 100644
index 000000000..011495801
--- /dev/null
+++ b/blocks/analog/test/qa_FmDet.cpp
@@ -0,0 +1,82 @@
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include
+#include // to build an end‑to‑end loop
+
+using namespace gr;
+using namespace gr::testing;
+using namespace gr::blocks::analog;
+using namespace boost::ut;
+
+template
+std::vector run_pipeline(const std::vector& drive,
+ property_map det_cfg = {})
+{
+ Graph g;
+ auto& src = g.emplaceBlock>(
+ {{"values", drive}, {"n_samples_max", drive.size()}});
+ auto& det = g.emplaceBlock>>(std::move(det_cfg));
+ auto& sink = g.emplaceBlock>();
+
+ expect(eq(g.connect<"out">(src ).template to<"in">(det ), ConnectionResult::SUCCESS));
+ expect(eq(g.connect<"out">(det ).template to<"in">(sink), ConnectionResult::SUCCESS));
+
+ scheduler::Simple sch{ std::move(g) };
+ expect(bool{ sch.runAndWait() });
+ return sink._samples;
+}
+
+static constexpr auto feq = [](float a, float b, float tol = 1e-6f) {
+ return std::fabs(a - b) <= tol;
+};
+
+suite fm_det = [] {
+
+ "scale / freq‑range setters"_test = [] {
+ FmDet> det;
+
+ det.set_freq_range(1.0f, 2.0f);
+ expect(feq(det.freq_low (), 1.0f));
+ expect(feq(det.freq_high(), 2.0f));
+
+ det.set_scale(4.0f);
+ expect(feq(det.scale(), 4.0f));
+
+ /* bias = ½·scl·(hi+lo)/(hi‑lo) = ½·4·3 / 1 = 6 */
+ expect(feq(det.bias(), 6.0f));
+ };
+
+ "end‑to‑end FM → slope‑det"_test = [] {
+ constexpr float f0 = 0.125f; // Hz (matches sens so y≈1)
+ constexpr float fs [[maybe_unused]] = 1.0f; // kept for clarity
+ constexpr float sens = std::numbers::pi_v / 4.0f;
+ constexpr float gain = 1.0f / sens;
+ constexpr size_t N = 100;
+
+ std::vector> drive;
+ drive.reserve(N);
+ for (size_t i = 0; i < N; ++i) {
+ float phase = 2.0f * std::numbers::pi_v * f0 *
+ static_cast(i);
+ drive.emplace_back(std::cos(phase), std::sin(phase));
+ }
+
+ auto y = run_pipeline, float>(
+ drive, {{"scl", gain}}); // detector scale
+
+ /* first sample undefined (prev‑sample = 0) – ignore */
+ expect(y.size() == N);
+ for (size_t i = 1; i < N; ++i)
+ expect(feq(y[i], 1.0f, 5e-2f)) << "k=" << i << " got " << y[i];
+ };
+};
+
+int main() { } // Boost.UT launches suites before entering main
diff --git a/blocks/analog/test/qa_FrequencyMod.cpp b/blocks/analog/test/qa_FrequencyMod.cpp
new file mode 100644
index 000000000..102e391dd
--- /dev/null
+++ b/blocks/analog/test/qa_FrequencyMod.cpp
@@ -0,0 +1,42 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace gr;
+using namespace gr::testing;
+using namespace gr::blocks::analog;
+using namespace boost::ut;
+
+suite freqmod_tests = [] {
+ "frequency modulator"_test = [] {
+ constexpr float k = std::numbers::pi_v/4.0f; // sensitivity
+ const float src_data[] = {0.25f,0.5f,0.25f,-0.25f,-0.5f,-0.25f};
+
+ auto sincos = [](float ph){ return std::complex(std::cos(ph),std::sin(ph)); };
+ std::vector> ref;
+ { float acc = 0.0f;
+ for(float x: src_data){ acc += k*x; ref.push_back(sincos(acc)); } }
+
+ Graph g;
+ auto& src = g.emplaceBlock>(property_map{{"values",std::vector(std::begin(src_data),std::end(src_data))},
+ {"n_samples_max",std::size(src_data)}});
+ auto& mod = g.emplaceBlock>(property_map{{"sensitivity",k}});
+ auto& sink = g.emplaceBlock,ProcessFunction::USE_PROCESS_BULK>>();
+
+ expect(eq(g.connect<"out">(src).to<"in">(mod),ConnectionResult::SUCCESS));
+ expect(eq(g.connect<"out">(mod).to<"in">(sink),ConnectionResult::SUCCESS));
+
+ scheduler::Simple sch{std::move(g)};
+ expect(bool{sch.runAndWait()});
+
+ expect(sink._samples.size() == ref.size());
+ for(std::size_t i=0;i
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+using namespace gr;
+using namespace gr::testing;
+using namespace gr::blocks::analog;
+using namespace boost::ut;
+
+template>
+std::vector run(const std::vector& drive, float sens)
+{
+ Graph g;
+
+ auto& src = g.emplaceBlock>();
+ src.values = drive; // emit exactly once
+
+ auto& mod = g.emplaceBlock();
+ mod.set_sensitivity(sens);
+
+ auto& sink =
+ g.emplaceBlock>();
+
+ [[maybe_unused]] auto _c1 =
+ g.connect<"out">(src).template to<"in">(mod);
+ [[maybe_unused]] auto _c2 =
+ g.connect<"out">(mod).template to<"in">(sink);
+
+ scheduler::Simple sch{ std::move(g) };
+ expect(bool{ sch.runAndWait() });
+
+ return sink._samples;
+}
+
+suite phase_mod = [] {
+ "basic"_test = [] {
+ constexpr float sens = std::numbers::pi_v / 4.f;
+
+ const std::vector drive = { 0.25f, 0.5f, 0.25f,
+ -0.25f, -0.5f, -0.25f };
+
+ std::vector> ref;
+ ref.reserve(drive.size());
+ for (auto v : drive) {
+ const float phi = sens * v;
+ ref.emplace_back(std::cos(phi), std::sin(phi));
+ }
+
+ auto y = run(drive, sens);
+
+ expect(y.size() >= ref.size());
+
+ constexpr float tol = 1e-5f;
+ for (std::size_t i = 0; i < ref.size(); ++i) {
+ expect(std::abs(y[i].real() - ref[i].real()) < tol);
+ expect(std::abs(y[i].imag() - ref[i].imag()) < tol);
+ }
+ };
+};
+
+int main() {} // Boost.UT auto‑runs suites
diff --git a/blocks/analog/test/qa_QuadratureDemod.cpp b/blocks/analog/test/qa_QuadratureDemod.cpp
new file mode 100644
index 000000000..66d3dc482
--- /dev/null
+++ b/blocks/analog/test/qa_QuadratureDemod.cpp
@@ -0,0 +1,83 @@
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+
+using namespace gr;
+using namespace gr::testing;
+using namespace gr::blocks::analog;
+using namespace boost::ut;
+
+template
+std::vector