diff --git a/oscope-sphere/src/SpectrogramBuffer.cpp b/oscope-sphere/src/SpectrogramBuffer.cpp new file mode 100644 index 0000000..45186ee --- /dev/null +++ b/oscope-sphere/src/SpectrogramBuffer.cpp @@ -0,0 +1,55 @@ +#include "SpectrogramBuffer.h" +#include +#include + +namespace oscope { + +SpectrogramBuffer::SpectrogramBuffer(int width, int height) + : width_(std::max(1, width)), + height_(std::max(2, height - (height % 2))) { + data_.assign(static_cast(width_) * height_, 0.0f); +} + +float SpectrogramBuffer::logResample(const std::vector& mag, + float frac01) { + if (mag.size() < 2) return 0.0f; + const float lo = 1.0f; + const float hi = static_cast(mag.size() - 1); + const float f = std::clamp(frac01, 0.0f, 1.0f); + const float bin = lo * std::pow(hi / lo, f); + const int i0 = static_cast(bin); + const int i1 = std::min(i0 + 1, static_cast(mag.size()) - 1); + const float t = bin - static_cast(i0); + return mag[i0] * (1.0f - t) + mag[i1] * t; +} + +float SpectrogramBuffer::norm01(float magnitude) { + const float db = 20.0f * std::log10(std::max(magnitude, 1e-6f)); + return std::clamp((db + 60.0f) / 60.0f, 0.0f, 1.0f); +} + +void SpectrogramBuffer::pushColumn(const std::vector& magCh1, + const std::vector& magCh2) { + const int col = writeIndex_; + const int half = height_ / 2; + + // CH1 -> northern rows [half, height_): row half = equator (low freq), + // row height_-1 = north pole (high freq). + for (int r = half; r < height_; ++r) { + const float frac = static_cast(r - half) / + static_cast(half - 1); + data_[static_cast(r) * width_ + col] = + norm01(logResample(magCh1, frac)); + } + // CH2 -> southern rows [0, half): row half-1 = equator (low freq), + // row 0 = south pole (high freq). + for (int r = 0; r < half; ++r) { + const float frac = static_cast(half - 1 - r) / + static_cast(half - 1); + data_[static_cast(r) * width_ + col] = + norm01(logResample(magCh2, frac)); + } + writeIndex_ = (writeIndex_ + 1) % width_; +} + +} // namespace oscope diff --git a/oscope-sphere/src/SpectrogramBuffer.h b/oscope-sphere/src/SpectrogramBuffer.h new file mode 100644 index 0000000..09ac445 --- /dev/null +++ b/oscope-sphere/src/SpectrogramBuffer.h @@ -0,0 +1,35 @@ +#pragma once +#include + +namespace oscope { + +// Pure-C++ rolling spectrogram column store. No GL dependency. +// Layout: row-major, data()[row * width + col]. +// Height is forced even. Rows [H/2, H) hold CH1 (equator -> north pole), +// rows [0, H/2) hold CH2 (equator -> south pole). Frequency is log-scaled +// along the rows; magnitudes are normalised to [0,1] via a -60..0 dB map. +class SpectrogramBuffer { +public: + SpectrogramBuffer(int width, int height); + + // magCh1 / magCh2: raw FFT magnitudes. Log-resampled into one column + // at the current write index, which then advances modulo width. + void pushColumn(const std::vector& magCh1, + const std::vector& magCh2); + + int width() const { return width_; } + int height() const { return height_; } + int writeIndex() const { return writeIndex_; } + const std::vector& data() const { return data_; } + +private: + static float logResample(const std::vector& mag, float frac01); + static float norm01(float magnitude); + + int width_; + int height_; + int writeIndex_ = 0; + std::vector data_; +}; + +} // namespace oscope diff --git a/oscope-sphere/tests/test_spectrogram.cpp b/oscope-sphere/tests/test_spectrogram.cpp new file mode 100644 index 0000000..01b9a23 --- /dev/null +++ b/oscope-sphere/tests/test_spectrogram.cpp @@ -0,0 +1,32 @@ +#include "check.h" +#include "SpectrogramBuffer.h" +#include + +using oscope::SpectrogramBuffer; + +// writeIndex advances modulo width. +static void test_write_index_wraps() { + SpectrogramBuffer buf(8, 4); + std::vector z(1024, 0.0f); + for (int i = 0; i < 10; ++i) buf.pushColumn(z, z); + CHECK(buf.writeIndex() == 2); // 10 % 8 + CHECK(static_cast(buf.data().size()) == 8 * 4); +} + +// High-frequency energy must land near the poles for both channels. +static void test_high_freq_maps_to_poles() { + SpectrogramBuffer buf(4, 8); // H=8 -> CH1 rows 4..7, CH2 rows 0..3 + std::vector hi(1024, 0.0f); + hi[1023] = 100.0f; // energy at the top FFT bin + buf.pushColumn(hi, hi); // both channels: high-frequency content + const std::vector& d = buf.data(); + const int W = buf.width(); + CHECK(d[7 * W + 0] > d[4 * W + 0]); // CH1: north pole > equator + CHECK(d[0 * W + 0] > d[3 * W + 0]); // CH2: south pole > equator +} + +int main() { + test_write_index_wraps(); + test_high_freq_maps_to_poles(); + REPORT(); +}