feat: add rolling spectrogram column buffer

Pure-C++ store that log-resamples both channels' FFT magnitudes into
scrolling columns, CH1 north and CH2 south, high frequencies toward
the poles. No GL dependency, fully unit-tested.
This commit is contained in:
L'électron rare
2026-05-18 16:03:29 +02:00
parent 1848368bc2
commit b8d76ccb27
3 changed files with 122 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
#include "SpectrogramBuffer.h"
#include <algorithm>
#include <cmath>
namespace oscope {
SpectrogramBuffer::SpectrogramBuffer(int width, int height)
: width_(std::max(1, width)),
height_(std::max(2, height - (height % 2))) {
data_.assign(static_cast<std::size_t>(width_) * height_, 0.0f);
}
float SpectrogramBuffer::logResample(const std::vector<float>& mag,
float frac01) {
if (mag.size() < 2) return 0.0f;
const float lo = 1.0f;
const float hi = static_cast<float>(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<int>(bin);
const int i1 = std::min(i0 + 1, static_cast<int>(mag.size()) - 1);
const float t = bin - static_cast<float>(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<float>& magCh1,
const std::vector<float>& 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<float>(r - half) /
static_cast<float>(half - 1);
data_[static_cast<std::size_t>(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<float>(half - 1 - r) /
static_cast<float>(half - 1);
data_[static_cast<std::size_t>(r) * width_ + col] =
norm01(logResample(magCh2, frac));
}
writeIndex_ = (writeIndex_ + 1) % width_;
}
} // namespace oscope
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <vector>
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<float>& magCh1,
const std::vector<float>& magCh2);
int width() const { return width_; }
int height() const { return height_; }
int writeIndex() const { return writeIndex_; }
const std::vector<float>& data() const { return data_; }
private:
static float logResample(const std::vector<float>& mag, float frac01);
static float norm01(float magnitude);
int width_;
int height_;
int writeIndex_ = 0;
std::vector<float> data_;
};
} // namespace oscope
+32
View File
@@ -0,0 +1,32 @@
#include "check.h"
#include "SpectrogramBuffer.h"
#include <vector>
using oscope::SpectrogramBuffer;
// writeIndex advances modulo width.
static void test_write_index_wraps() {
SpectrogramBuffer buf(8, 4);
std::vector<float> z(1024, 0.0f);
for (int i = 0; i < 10; ++i) buf.pushColumn(z, z);
CHECK(buf.writeIndex() == 2); // 10 % 8
CHECK(static_cast<int>(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<float> 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<float>& 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();
}