commit no. 3

This commit is contained in:
2026-03-25 19:47:16 +01:00
parent f45278123f
commit cc5241a1e5
11 changed files with 541 additions and 218 deletions

View File

@@ -103,7 +103,6 @@ struct AnalyzerSettings {
bool isIQ = false; // true → complex input (2-ch interleaved) bool isIQ = false; // true → complex input (2-ch interleaved)
int numChannels = 1; // real channels (ignored when isIQ) int numChannels = 1; // real channels (ignored when isIQ)
double sampleRate = 48000.0; double sampleRate = 48000.0;
int averaging = 1; // number of spectra to average (1 = none)
// Effective input channel count (for buffer sizing / deinterleaving). // Effective input channel count (for buffer sizing / deinterleaving).
int inputChannels() const { return isIQ ? 2 : numChannels; } int inputChannels() const { return isIQ ? 2 : numChannels; }

View File

@@ -37,9 +37,6 @@ void SpectrumAnalyzer::configure(const AnalyzerSettings& settings) {
channelComplex_.assign(nSpec, std::vector<std::complex<float>>(specSz, {0,0})); channelComplex_.assign(nSpec, std::vector<std::complex<float>>(specSz, {0,0}));
channelWaterfalls_.assign(nSpec, {}); channelWaterfalls_.assign(nSpec, {});
avgAccum_.assign(nSpec, std::vector<float>(specSz, 0.0f));
avgCount_ = 0;
newSpectrumReady_ = false; newSpectrumReady_ = false;
} }
} }
@@ -105,28 +102,6 @@ void SpectrumAnalyzer::processBlock() {
for (float& v : db) for (float& v : db)
v += correction; v += correction;
// Averaging (dB only; complex is not averaged — last block is kept).
if (settings_.averaging > 1) {
if (static_cast<int>(avgAccum_[0].size()) != specSz) {
for (auto& a : avgAccum_) a.assign(specSz, 0.0f);
avgCount_ = 0;
}
for (int ch = 0; ch < nSpec; ++ch)
for (int i = 0; i < specSz; ++i)
avgAccum_[ch][i] += tempDBs[ch][i];
avgCount_++;
if (avgCount_ >= settings_.averaging) {
for (int ch = 0; ch < nSpec; ++ch)
for (int i = 0; i < specSz; ++i)
tempDBs[ch][i] = avgAccum_[ch][i] / avgCount_;
for (auto& a : avgAccum_) a.assign(specSz, 0.0f);
avgCount_ = 0;
} else {
return;
}
}
for (int ch = 0; ch < nSpec; ++ch) { for (int ch = 0; ch < nSpec; ++ch) {
channelSpectra_[ch] = tempDBs[ch]; channelSpectra_[ch] = tempDBs[ch];
channelComplex_[ch] = tempCplx[ch]; channelComplex_[ch] = tempCplx[ch];

View File

@@ -58,10 +58,6 @@ private:
size_t accumPos_ = 0; size_t accumPos_ = 0;
size_t hopSize_ = 0; size_t hopSize_ = 0;
// Per-channel averaging
std::vector<std::vector<float>> avgAccum_;
int avgCount_ = 0;
// Per-channel output: magnitude (dB) and complex // Per-channel output: magnitude (dB) and complex
std::vector<std::vector<float>> channelSpectra_; std::vector<std::vector<float>> channelSpectra_;
std::vector<std::vector<std::complex<float>>> channelComplex_; std::vector<std::vector<std::complex<float>>> channelComplex_;

View File

@@ -270,13 +270,69 @@ void Application::render() {
ImGui::SameLine(); ImGui::SameLine();
// Spectrum + Waterfall // Spectrum + Waterfall with draggable splitter
ImGui::BeginChild("Display", {contentW, contentH}, false); ImGui::BeginChild("Display", {contentW, contentH}, false);
float specH = contentH * 0.35f; {
float waterfH = contentH * 0.65f - 4; constexpr float kSplitterH = 6.0f;
float specH = contentH * spectrumFrac_;
float waterfH = contentH - specH - kSplitterH;
renderSpectrumPanel(); renderSpectrumPanel();
// ── Draggable splitter bar ──
ImVec2 splPos = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton("##splitter", {contentW, kSplitterH});
bool hovered = ImGui::IsItemHovered();
bool active = ImGui::IsItemActive();
if (hovered || active)
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS);
if (active) {
float dy = ImGui::GetIO().MouseDelta.y;
spectrumFrac_ += dy / contentH;
spectrumFrac_ = std::clamp(spectrumFrac_, 0.1f, 0.9f);
}
// Draw splitter line
ImU32 splCol = (hovered || active)
? IM_COL32(100, 150, 255, 220)
: IM_COL32(80, 80, 100, 150);
ImDrawList* dl = ImGui::GetWindowDrawList();
float cy = splPos.y + kSplitterH * 0.5f;
dl->AddLine({splPos.x, cy}, {splPos.x + contentW, cy}, splCol, 2.0f);
renderWaterfallPanel(); renderWaterfallPanel();
// ── Cross-panel hover line & frequency label ──
if (cursors_.hover.active && specSizeX_ > 0 && wfSizeX_ > 0) {
ImDrawList* dlp = ImGui::GetWindowDrawList();
float hx = specDisplay_.freqToScreenX(cursors_.hover.freq,
specPosX_, specSizeX_, settings_.sampleRate,
settings_.isIQ, freqScale_, viewLo_, viewHi_);
ImU32 hoverCol = IM_COL32(200, 200, 200, 80);
// Line spanning spectrum + splitter + waterfall
dlp->AddLine({hx, specPosY_}, {hx, wfPosY_ + wfSizeY_}, hoverCol, 1.0f);
// Frequency label at top of the line
char freqLabel[48];
double hf = cursors_.hover.freq;
if (std::abs(hf) >= 1e6)
std::snprintf(freqLabel, sizeof(freqLabel), "%.6f MHz", hf / 1e6);
else if (std::abs(hf) >= 1e3)
std::snprintf(freqLabel, sizeof(freqLabel), "%.3f kHz", hf / 1e3);
else
std::snprintf(freqLabel, sizeof(freqLabel), "%.1f Hz", hf);
ImVec2 tSz = ImGui::CalcTextSize(freqLabel);
float lx = std::min(hx + 4, specPosX_ + specSizeX_ - tSz.x - 4);
float ly = specPosY_ + 2;
dlp->AddRectFilled({lx - 2, ly - 1}, {lx + tSz.x + 2, ly + tSz.y + 1},
IM_COL32(0, 0, 0, 180));
dlp->AddText({lx, ly}, IM_COL32(220, 220, 240, 240), freqLabel);
}
}
ImGui::EndChild(); ImGui::EndChild();
ImGui::End(); ImGui::End();
@@ -356,12 +412,35 @@ void Application::renderControlPanel() {
} }
} }
// Overlap // Overlap — inverted x⁴ curve: sensitive at the high end (90%+).
if (ImGui::SliderFloat("Overlap", &overlapPct_, 0.0f, 95.0f, "%.1f%%")) { {
int hopSamples = static_cast<int>(settings_.fftSize * (1.0f - settings_.overlap));
if (hopSamples < 1) hopSamples = 1;
int overlapSamples = settings_.fftSize - hopSamples;
float sliderVal = 1.0f - std::pow(1.0f - overlapPct_ / 99.0f, 0.25f);
if (ImGui::SliderFloat("Overlap", &sliderVal, 0.0f, 1.0f, "")) {
float inv = 1.0f - sliderVal;
float inv2 = inv * inv;
overlapPct_ = 99.0f * (1.0f - inv2 * inv2);
settings_.overlap = overlapPct_ / 100.0f; settings_.overlap = overlapPct_ / 100.0f;
updateAnalyzerSettings(); updateAnalyzerSettings();
} }
// Draw overlay text centered on the slider frame (not the label).
char overlayText[64];
std::snprintf(overlayText, sizeof(overlayText), "%.1f%% (%d samples)", overlapPct_, overlapSamples);
ImVec2 textSize = ImGui::CalcTextSize(overlayText);
// The slider frame width = total widget width minus label.
// ImGui::CalcItemWidth() gives the frame width.
ImVec2 sliderMin = ImGui::GetItemRectMin();
float frameW = ImGui::CalcItemWidth();
float frameH = ImGui::GetItemRectMax().y - sliderMin.y;
float tx = sliderMin.x + (frameW - textSize.x) * 0.5f;
float ty = sliderMin.y + (frameH - textSize.y) * 0.5f;
ImGui::GetForegroundDrawList()->AddText({tx, ty}, IM_COL32(255, 255, 255, 220), overlayText);
}
// Window function // Window function
{ {
const char* winNames[] = {"Rectangular", "Hann", "Hamming", "Blackman", const char* winNames[] = {"Rectangular", "Hann", "Hamming", "Blackman",
@@ -382,9 +461,6 @@ void Application::renderControlPanel() {
} }
} }
// Averaging
ImGui::SliderInt("Averaging", &settings_.averaging, 1, 32);
ImGui::Separator(); ImGui::Separator();
ImGui::Text("Display"); ImGui::Text("Display");
@@ -406,10 +482,34 @@ void Application::renderControlPanel() {
freqScale_ = static_cast<FreqScale>(fs); freqScale_ = static_cast<FreqScale>(fs);
} }
// Zoom info & reset
if (viewLo_ > 0.0f || viewHi_ < 1.0f) {
float zoomPct = 1.0f / (viewHi_ - viewLo_);
ImGui::Text("Zoom: %.1fx", zoomPct);
ImGui::SameLine();
if (ImGui::SmallButton("Reset")) {
viewLo_ = 0.0f;
viewHi_ = 1.0f;
}
}
ImGui::TextDisabled("Scroll: freq zoom | MMB drag: pan");
ImGui::TextDisabled("Ctrl+Scroll: dB zoom | MMB dbl: reset");
// dB range // dB range
ImGui::DragFloatRange2("dB Range", &minDB_, &maxDB_, 1.0f, -200.0f, 20.0f, ImGui::DragFloatRange2("dB Range", &minDB_, &maxDB_, 1.0f, -200.0f, 20.0f,
"Min: %.0f", "Max: %.0f"); "Min: %.0f", "Max: %.0f");
// Peak hold
ImGui::Checkbox("Peak Hold", &specDisplay_.peakHoldEnable);
if (specDisplay_.peakHoldEnable) {
ImGui::SameLine();
ImGui::SetNextItemWidth(80);
ImGui::SliderFloat("Decay", &specDisplay_.peakHoldDecay, 0.0f, 120.0f, "%.0f dB/s");
ImGui::SameLine();
if (ImGui::SmallButton("Clear##peakhold"))
specDisplay_.clearPeakHold();
}
// Channel colors (only shown for multi-channel) // Channel colors (only shown for multi-channel)
int nCh = analyzer_.numSpectra(); int nCh = analyzer_.numSpectra();
if (nCh > 1) { if (nCh > 1) {
@@ -486,7 +586,11 @@ void Application::renderControlPanel() {
void Application::renderSpectrumPanel() { void Application::renderSpectrumPanel() {
float availW = ImGui::GetContentRegionAvail().x; float availW = ImGui::GetContentRegionAvail().x;
float specH = ImGui::GetContentRegionAvail().y * 0.35f; // Use the parent's full content height (availY includes spectrum + splitter + waterfall)
// to compute the spectrum height from the split fraction.
constexpr float kSplitterH = 6.0f;
float parentH = ImGui::GetContentRegionAvail().y;
float specH = (parentH - kSplitterH) * spectrumFrac_;
ImVec2 pos = ImGui::GetCursorScreenPos(); ImVec2 pos = ImGui::GetCursorScreenPos();
specPosX_ = pos.x; specPosX_ = pos.x;
@@ -526,12 +630,15 @@ void Application::renderSpectrumPanel() {
} }
} }
specDisplay_.updatePeakHold(allSpectra);
specDisplay_.draw(allSpectra, styles, minDB_, maxDB_, specDisplay_.draw(allSpectra, styles, minDB_, maxDB_,
settings_.sampleRate, settings_.isIQ, freqScale_, settings_.sampleRate, settings_.isIQ, freqScale_,
specPosX_, specPosY_, specSizeX_, specSizeY_); specPosX_, specPosY_, specSizeX_, specSizeY_,
viewLo_, viewHi_);
cursors_.draw(specDisplay_, specPosX_, specPosY_, specSizeX_, specSizeY_, cursors_.draw(specDisplay_, specPosX_, specPosY_, specSizeX_, specSizeY_,
settings_.sampleRate, settings_.isIQ, freqScale_, minDB_, maxDB_); settings_.sampleRate, settings_.isIQ, freqScale_, minDB_, maxDB_,
viewLo_, viewHi_);
handleSpectrumInput(specPosX_, specPosY_, specSizeX_, specSizeY_); handleSpectrumInput(specPosX_, specPosY_, specSizeX_, specSizeY_);
@@ -542,64 +649,113 @@ void Application::renderWaterfallPanel() {
float availW = ImGui::GetContentRegionAvail().x; float availW = ImGui::GetContentRegionAvail().x;
float availH = ImGui::GetContentRegionAvail().y; float availH = ImGui::GetContentRegionAvail().y;
int newW = static_cast<int>(availW); // Fixed history depth — independent of screen height, so resizing the
int newH = static_cast<int>(availH); // splitter doesn't recreate the texture every frame.
if (newW < 1) newW = 1; constexpr int kHistoryRows = 1024;
if (newH < 1) newH = 1;
if (newW != waterfallW_ || newH != waterfallH_) { // Only recreate when the bin count (FFT size) changes.
waterfallW_ = newW; int binCount = std::max(1, analyzer_.spectrumSize());
waterfallH_ = newH; if (binCount != waterfall_.width() || waterfall_.height() != kHistoryRows) {
waterfall_.resize(waterfallW_, waterfallH_); waterfall_.resize(binCount, kHistoryRows);
waterfall_.setColorMap(colorMap_); waterfall_.setColorMap(colorMap_);
} }
if (waterfall_.textureID()) { if (waterfall_.textureID()) {
// Render waterfall texture with circular buffer offset.
// The texture rows wrap: currentRow_ is where the *next* line will go,
// so the *newest* line is at currentRow_+1.
float rowFrac = static_cast<float>(waterfall_.currentRow() + 1) /
waterfall_.height();
// UV coordinates: bottom of display = newest = rowFrac
// top of display = oldest = rowFrac + 1.0 (wraps)
// We'll use two draw calls to handle the wrap, or use GL_REPEAT.
// Simplest: just render with ImGui::Image and accept minor visual glitch,
// or split into two parts.
ImVec2 pos = ImGui::GetCursorScreenPos(); ImVec2 pos = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList(); ImDrawList* dl = ImGui::GetWindowDrawList();
auto texID = static_cast<ImTextureID>(waterfall_.textureID()); auto texID = static_cast<ImTextureID>(waterfall_.textureID());
int h = waterfall_.height(); int h = waterfall_.height();
int cur = (waterfall_.currentRow() + 1) % h; // The newest row was just written at currentRow()+1 (mod h) — but
float splitFrac = static_cast<float>(h - cur) / h; // advanceRow already decremented, so currentRow() IS the newest.
// The row *after* currentRow() (i.e. currentRow()+1) is the oldest
// visible row. We only want the most recent screenRows rows so
// that every texture row maps to exactly one screen pixel.
int screenRows = std::min(static_cast<int>(availH), h);
// Top part: rows from cur to h-1 (oldest) // Newest row index in the circular buffer.
float topH = availH * splitFrac; int newestRow = (waterfall_.currentRow() + 1) % h;
dl->AddImage(texID,
{pos.x, pos.y},
{pos.x + availW, pos.y + topH},
{0.0f, static_cast<float>(cur) / h},
{1.0f, 1.0f});
// Bottom part: rows from 0 to cur-1 (newest) // Render 1:1 (one texture row = one screen pixel), top-aligned,
if (cur > 0) { // newest line at top (right below the spectrogram), scrolling down.
//
// advanceRow() decrements currentRow_, so rows are written at
// decreasing indices. Going from newest to oldest = increasing
// index (mod h). Normal V order (no flip needed).
float rowToV = 1.0f / h;
float screenY = pos.y;
bool logMode = (freqScale_ == FreqScale::Logarithmic && !settings_.isIQ);
auto drawSpan = [&](int rowStart, int rowCount, float yStart, float spanH) {
float v0 = rowStart * rowToV;
float v1 = (rowStart + rowCount) * rowToV;
if (!logMode) {
dl->AddImage(texID, dl->AddImage(texID,
{pos.x, pos.y + topH}, {pos.x, yStart},
{pos.x + availW, pos.y + availH}, {pos.x + availW, yStart + spanH},
{0.0f, 0.0f}, {viewLo_, v0}, {viewHi_, v1});
{1.0f, static_cast<float>(cur) / h}); } else {
constexpr float kMinBinFrac = 0.001f;
float logMin2 = std::log10(kMinBinFrac);
float logMax2 = 0.0f;
int numStrips = std::min(512, static_cast<int>(availW));
for (int s = 0; s < numStrips; ++s) {
float sL = static_cast<float>(s) / numStrips;
float sR = static_cast<float>(s + 1) / numStrips;
float vfL = viewLo_ + sL * (viewHi_ - viewLo_);
float vfR = viewLo_ + sR * (viewHi_ - viewLo_);
float uL = std::pow(10.0f, logMin2 + vfL * (logMax2 - logMin2));
float uR = std::pow(10.0f, logMin2 + vfR * (logMax2 - logMin2));
dl->AddImage(texID,
{pos.x + sL * availW, yStart},
{pos.x + sR * availW, yStart + spanH},
{uL, v0}, {uR, v1});
}
}
};
// From newestRow, walk forward (increasing index mod h) for
// screenRows steps to cover newest→oldest.
if (newestRow + screenRows <= h) {
// No wrap: rows [newestRow .. newestRow+screenRows)
drawSpan(newestRow, screenRows, screenY, static_cast<float>(screenRows));
} else {
// Wraps: [newestRow .. h), then [0 .. remainder)
int firstCount = h - newestRow;
float firstH = static_cast<float>(firstCount);
drawSpan(newestRow, firstCount, screenY, firstH);
int secondCount = screenRows - firstCount;
float secondH = static_cast<float>(secondCount);
if (secondCount > 0)
drawSpan(0, secondCount, screenY + firstH, secondH);
} }
// Frequency axis labels at bottom // ── Frequency axis labels ──
ImU32 textCol = IM_COL32(180, 180, 200, 200); ImU32 textCol = IM_COL32(180, 180, 200, 200);
double freqMin = settings_.isIQ ? -settings_.sampleRate / 2.0 : 0.0; double freqFullMin = settings_.isIQ ? -settings_.sampleRate / 2.0 : 0.0;
double freqMax = settings_.isIQ ? settings_.sampleRate / 2.0 : settings_.sampleRate / 2.0; double freqFullMax = settings_.isIQ ? settings_.sampleRate / 2.0 : settings_.sampleRate / 2.0;
// Map a view fraction to frequency. In log mode, viewLo_/viewHi_
// are in screen-fraction space; convert via the log mapping.
auto viewFracToFreq = [&](float vf) -> double {
if (logMode) {
constexpr float kMinBinFrac = 0.001f;
float logMin2 = std::log10(kMinBinFrac);
float logMax2 = 0.0f;
float binFrac = std::pow(10.0f, logMin2 + vf * (logMax2 - logMin2));
return freqFullMin + binFrac * (freqFullMax - freqFullMin);
}
return freqFullMin + vf * (freqFullMax - freqFullMin);
};
int numLabels = 8; int numLabels = 8;
for (int i = 0; i <= numLabels; ++i) { for (int i = 0; i <= numLabels; ++i) {
float frac = static_cast<float>(i) / numLabels; float frac = static_cast<float>(i) / numLabels;
double freq = freqMin + frac * (freqMax - freqMin); float vf = viewLo_ + frac * (viewHi_ - viewLo_);
double freq = viewFracToFreq(vf);
float x = pos.x + frac * availW; float x = pos.x + frac * availW;
char label[32]; char label[32];
@@ -612,6 +768,75 @@ void Application::renderWaterfallPanel() {
dl->AddText({x + 2, pos.y + availH - 14}, textCol, label); dl->AddText({x + 2, pos.y + availH - 14}, textCol, label);
} }
// Store waterfall geometry for cross-panel cursor drawing.
wfPosX_ = pos.x; wfPosY_ = pos.y; wfSizeX_ = availW; wfSizeY_ = availH;
// ── Mouse interaction: zoom, pan & hover on waterfall ──
ImGuiIO& io = ImGui::GetIO();
float mx = io.MousePos.x;
float my = io.MousePos.y;
bool inWaterfall = mx >= pos.x && mx <= pos.x + availW &&
my >= pos.y && my <= pos.y + availH;
// Hover cursor from waterfall
if (inWaterfall) {
double freq = specDisplay_.screenXToFreq(mx, pos.x, availW,
settings_.sampleRate,
settings_.isIQ, freqScale_,
viewLo_, viewHi_);
int bins = analyzer_.spectrumSize();
double fMin = settings_.isIQ ? -settings_.sampleRate / 2.0 : 0.0;
double fMax = settings_.isIQ ? settings_.sampleRate / 2.0 : settings_.sampleRate / 2.0;
int bin = static_cast<int>((freq - fMin) / (fMax - fMin) * (bins - 1));
bin = std::clamp(bin, 0, bins - 1);
int curCh = std::clamp(waterfallChannel_, 0, analyzer_.numSpectra() - 1);
const auto& spec = analyzer_.channelSpectrum(curCh);
if (!spec.empty()) {
cursors_.hover = {true, freq, spec[bin], bin};
}
}
if (inWaterfall) {
// Scroll wheel: zoom centered on cursor
if (io.MouseWheel != 0) {
float cursorFrac = (mx - pos.x) / availW; // 0..1 on screen
float viewFrac = viewLo_ + cursorFrac * (viewHi_ - viewLo_);
float zoomFactor = (io.MouseWheel > 0) ? 0.85f : 1.0f / 0.85f;
float newSpan = (viewHi_ - viewLo_) * zoomFactor;
newSpan = std::clamp(newSpan, 0.001f, 1.0f);
float newLo = viewFrac - cursorFrac * newSpan;
float newHi = newLo + newSpan;
// Clamp to [0, 1]
if (newLo < 0.0f) { newHi -= newLo; newLo = 0.0f; }
if (newHi > 1.0f) { newLo -= (newHi - 1.0f); newHi = 1.0f; }
viewLo_ = std::clamp(newLo, 0.0f, 1.0f);
viewHi_ = std::clamp(newHi, 0.0f, 1.0f);
}
// Middle-click + drag: pan
if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 1.0f)) {
float dx = io.MouseDelta.x;
float panFrac = -dx / availW * (viewHi_ - viewLo_);
float newLo = viewLo_ + panFrac;
float newHi = viewHi_ + panFrac;
float span = viewHi_ - viewLo_;
if (newLo < 0.0f) { newLo = 0.0f; newHi = span; }
if (newHi > 1.0f) { newHi = 1.0f; newLo = 1.0f - span; }
viewLo_ = newLo;
viewHi_ = newHi;
}
// Double-click: reset zoom
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Middle)) {
viewLo_ = 0.0f;
viewHi_ = 1.0f;
}
}
} }
ImGui::Dummy({availW, availH}); ImGui::Dummy({availW, availH});
@@ -630,7 +855,8 @@ void Application::handleSpectrumInput(float posX, float posY,
// Update hover cursor // Update hover cursor
double freq = specDisplay_.screenXToFreq(mx, posX, sizeX, double freq = specDisplay_.screenXToFreq(mx, posX, sizeX,
settings_.sampleRate, settings_.sampleRate,
settings_.isIQ, freqScale_); settings_.isIQ, freqScale_,
viewLo_, viewHi_);
float dB = specDisplay_.screenYToDB(my, posY, sizeY, minDB_, maxDB_); float dB = specDisplay_.screenYToDB(my, posY, sizeY, minDB_, maxDB_);
// Find closest bin // Find closest bin
@@ -648,20 +874,21 @@ void Application::handleSpectrumInput(float posX, float posY,
} }
// Left click: cursor A // Left click: cursor A
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !io.WantCaptureMouse) { if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
int peakBin = cursors_.findLocalPeak(spec, bin, 10); int peakBin = cursors_.findLocalPeak(spec, bin, 10);
double peakFreq = analyzer_.binToFreq(peakBin); double peakFreq = analyzer_.binToFreq(peakBin);
cursors_.setCursorA(peakFreq, spec[peakBin], peakBin); cursors_.setCursorA(peakFreq, spec[peakBin], peakBin);
} }
// Right click: cursor B // Right click: cursor B
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !io.WantCaptureMouse) { if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
int peakBin = cursors_.findLocalPeak(spec, bin, 10); int peakBin = cursors_.findLocalPeak(spec, bin, 10);
double peakFreq = analyzer_.binToFreq(peakBin); double peakFreq = analyzer_.binToFreq(peakBin);
cursors_.setCursorB(peakFreq, spec[peakBin], peakBin); cursors_.setCursorB(peakFreq, spec[peakBin], peakBin);
} }
// Scroll: zoom dB range {
if (io.MouseWheel != 0 && !io.WantCaptureMouse) { // Ctrl+Scroll or Shift+Scroll: zoom dB range
if (io.MouseWheel != 0 && (io.KeyCtrl || io.KeyShift)) {
float zoom = io.MouseWheel * 5.0f; float zoom = io.MouseWheel * 5.0f;
minDB_ += zoom; minDB_ += zoom;
maxDB_ -= zoom; maxDB_ -= zoom;
@@ -671,6 +898,43 @@ void Application::handleSpectrumInput(float posX, float posY,
maxDB_ = mid + 5.0f; maxDB_ = mid + 5.0f;
} }
} }
// Scroll (no modifier): zoom frequency axis centered on cursor
else if (io.MouseWheel != 0) {
float cursorFrac = (mx - posX) / sizeX;
float viewFrac = viewLo_ + cursorFrac * (viewHi_ - viewLo_);
float zoomFactor = (io.MouseWheel > 0) ? 0.85f : 1.0f / 0.85f;
float newSpan = (viewHi_ - viewLo_) * zoomFactor;
newSpan = std::clamp(newSpan, 0.001f, 1.0f);
float newLo = viewFrac - cursorFrac * newSpan;
float newHi = newLo + newSpan;
if (newLo < 0.0f) { newHi -= newLo; newLo = 0.0f; }
if (newHi > 1.0f) { newLo -= (newHi - 1.0f); newHi = 1.0f; }
viewLo_ = std::clamp(newLo, 0.0f, 1.0f);
viewHi_ = std::clamp(newHi, 0.0f, 1.0f);
}
// Middle-click + drag: pan
if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 1.0f)) {
float dx = io.MouseDelta.x;
float panFrac = -dx / sizeX * (viewHi_ - viewLo_);
float newLo = viewLo_ + panFrac;
float newHi = viewHi_ + panFrac;
float span = viewHi_ - viewLo_;
if (newLo < 0.0f) { newLo = 0.0f; newHi = span; }
if (newHi > 1.0f) { newHi = 1.0f; newLo = 1.0f - span; }
viewLo_ = newLo;
viewHi_ = newHi;
}
// Double middle-click: reset zoom
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Middle)) {
viewLo_ = 0.0f;
viewHi_ = 1.0f;
}
}
} else { } else {
cursors_.hover.active = false; cursors_.hover.active = false;
} }
@@ -747,8 +1011,9 @@ void Application::updateAnalyzerSettings() {
// Re-init waterfall texture so the old image from a different FFT // Re-init waterfall texture so the old image from a different FFT
// size doesn't persist. // size doesn't persist.
if (waterfallW_ > 0 && waterfallH_ > 0) constexpr int kHistoryRows = 1024;
waterfall_.init(waterfallW_, waterfallH_); int binCount2 = std::max(1, analyzer_.spectrumSize());
waterfall_.init(binCount2, kHistoryRows);
} }
} }

View File

@@ -113,8 +113,8 @@ private:
float maxDB_ = 0.0f; float maxDB_ = 0.0f;
FreqScale freqScale_ = FreqScale::Linear; FreqScale freqScale_ = FreqScale::Linear;
bool paused_ = false; bool paused_ = false;
int waterfallW_ = 0; // (waterfallW_ removed — texture width tracks bin count automatically)
int waterfallH_ = 0; // (waterfallH_ removed — fixed history depth of 1024 rows)
// FFT size options // FFT size options
static constexpr int kFFTSizes[] = {256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}; static constexpr int kFFTSizes[] = {256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536};
@@ -159,8 +159,17 @@ private:
std::vector<MathChannel> mathChannels_; std::vector<MathChannel> mathChannels_;
std::vector<std::vector<float>> mathSpectra_; // computed each frame std::vector<std::vector<float>> mathSpectra_; // computed each frame
// Spectrum panel geometry (stored for cursor interaction) // Frequency zoom/pan (normalized 01 over full bandwidth)
float viewLo_ = 0.0f; // left edge
float viewHi_ = 1.0f; // right edge
// Spectrum/waterfall split ratio (fraction of content height for spectrum)
float spectrumFrac_ = 0.35f;
bool draggingSplit_ = false;
// Panel geometry (stored for cursor interaction)
float specPosX_ = 0, specPosY_ = 0, specSizeX_ = 0, specSizeY_ = 0; float specPosX_ = 0, specPosY_ = 0, specSizeX_ = 0, specSizeY_ = 0;
float wfPosX_ = 0, wfPosY_ = 0, wfSizeX_ = 0, wfSizeY_ = 0;
}; };
} // namespace baudline } // namespace baudline

View File

@@ -29,13 +29,15 @@ void Cursors::update(const std::vector<float>& spectrumDB,
void Cursors::draw(const SpectrumDisplay& specDisplay, void Cursors::draw(const SpectrumDisplay& specDisplay,
float posX, float posY, float sizeX, float sizeY, float posX, float posY, float sizeX, float sizeY,
double sampleRate, bool isIQ, FreqScale freqScale, double sampleRate, bool isIQ, FreqScale freqScale,
float minDB, float maxDB) const { float minDB, float maxDB,
float viewLo, float viewHi) const {
ImDrawList* dl = ImGui::GetWindowDrawList(); ImDrawList* dl = ImGui::GetWindowDrawList();
auto drawCursor = [&](const CursorInfo& c, ImU32 color, const char* label) { auto drawCursor = [&](const CursorInfo& c, ImU32 color, const char* label) {
if (!c.active) return; if (!c.active) return;
float x = specDisplay.freqToScreenX(c.freq, posX, sizeX, float x = specDisplay.freqToScreenX(c.freq, posX, sizeX,
sampleRate, isIQ, freqScale); sampleRate, isIQ, freqScale,
viewLo, viewHi);
float dbNorm = (c.dB - minDB) / (maxDB - minDB); float dbNorm = (c.dB - minDB) / (maxDB - minDB);
dbNorm = std::clamp(dbNorm, 0.0f, 1.0f); dbNorm = std::clamp(dbNorm, 0.0f, 1.0f);
float y = posY + sizeY * (1.0f - dbNorm); float y = posY + sizeY * (1.0f - dbNorm);
@@ -93,12 +95,7 @@ void Cursors::draw(const SpectrumDisplay& specDisplay,
dl->AddText({tx, ty}, IM_COL32(255, 200, 100, 255), buf); dl->AddText({tx, ty}, IM_COL32(255, 200, 100, 255), buf);
} }
// Hover cursor // (Hover cursor line is drawn cross-panel by Application.)
if (hover.active) {
float x = specDisplay.freqToScreenX(hover.freq, posX, sizeX,
sampleRate, isIQ, freqScale);
dl->AddLine({x, posY}, {x, posY + sizeY}, IM_COL32(200, 200, 200, 80), 1.0f);
}
} }
void Cursors::drawPanel() const { void Cursors::drawPanel() const {

View File

@@ -23,7 +23,8 @@ public:
void draw(const SpectrumDisplay& specDisplay, void draw(const SpectrumDisplay& specDisplay,
float posX, float posY, float sizeX, float sizeY, float posX, float posY, float sizeX, float sizeY,
double sampleRate, bool isIQ, FreqScale freqScale, double sampleRate, bool isIQ, FreqScale freqScale,
float minDB, float maxDB) const; float minDB, float maxDB,
float viewLo = 0.0f, float viewHi = 1.0f) const;
// Draw cursor readout panel (ImGui widgets). // Draw cursor readout panel (ImGui widgets).
void drawPanel() const; void drawPanel() const;

View File

@@ -12,12 +12,35 @@ static float freqToLogFrac(double freq, double minFreq, double maxFreq) {
return static_cast<float>((logF - logMin) / (logMax - logMin)); return static_cast<float>((logF - logMin) / (logMax - logMin));
} }
// Build a decimated polyline for one spectrum. // Map a "full-range screen fraction" (01) to a bin fraction, applying log if needed.
static float screenFracToBinFrac(float frac, FreqScale freqScale, bool isIQ) {
if (freqScale == FreqScale::Logarithmic && !isIQ) {
constexpr float kMinBinFrac = 0.001f;
float logMin = std::log10(kMinBinFrac);
float logMax = 0.0f;
return std::pow(10.0f, logMin + frac * (logMax - logMin));
}
return frac;
}
// Inverse: bin fraction → screen fraction.
static float binFracToScreenFrac(float binFrac, FreqScale freqScale, bool isIQ) {
if (freqScale == FreqScale::Logarithmic && !isIQ) {
constexpr float kMinBinFrac = 0.001f;
float logMin = std::log10(kMinBinFrac);
float logMax = 0.0f;
if (binFrac < kMinBinFrac) binFrac = kMinBinFrac;
return (std::log10(binFrac) - logMin) / (logMax - logMin);
}
return binFrac;
}
// Build a decimated polyline for one spectrum, considering view range.
static void buildPolyline(const std::vector<float>& spectrumDB, static void buildPolyline(const std::vector<float>& spectrumDB,
float minDB, float maxDB, float minDB, float maxDB,
double freqMin, double freqMax,
bool isIQ, FreqScale freqScale, bool isIQ, FreqScale freqScale,
float posX, float posY, float sizeX, float sizeY, float posX, float posY, float sizeX, float sizeY,
float viewLo, float viewHi,
std::vector<ImVec2>& outPoints) { std::vector<ImVec2>& outPoints) {
int bins = static_cast<int>(spectrumDB.size()); int bins = static_cast<int>(spectrumDB.size());
int displayPts = std::min(bins, static_cast<int>(sizeX)); int displayPts = std::min(bins, static_cast<int>(sizeX));
@@ -25,27 +48,26 @@ static void buildPolyline(const std::vector<float>& spectrumDB,
outPoints.resize(displayPts); outPoints.resize(displayPts);
for (int idx = 0; idx < displayPts; ++idx) { for (int idx = 0; idx < displayPts; ++idx) {
float frac = static_cast<float>(idx) / (displayPts - 1); float screenFrac = static_cast<float>(idx) / (displayPts - 1);
float xFrac; // Map screen pixel → full-range fraction via viewLo/viewHi
float viewFrac = viewLo + screenFrac * (viewHi - viewLo);
// Map to bin fraction (apply log scale if needed)
float binFrac = screenFracToBinFrac(viewFrac, freqScale, isIQ);
if (freqScale == FreqScale::Logarithmic && !isIQ) { float binF = binFrac * (bins - 1);
double freq = frac * (freqMax - freqMin) + freqMin;
double logMin = std::max(freqMin, 1.0);
xFrac = freqToLogFrac(freq, logMin, freqMax);
} else {
xFrac = frac;
}
// Bucket range for peak-hold decimation. // Bucket range for peak-hold decimation.
float binF = frac * (bins - 1); float prevViewFrac = (idx > 0)
float binPrev = (idx > 0) ? viewLo + static_cast<float>(idx - 1) / (displayPts - 1) * (viewHi - viewLo)
? static_cast<float>(idx - 1) / (displayPts - 1) * (bins - 1) : viewFrac;
: binF; float nextViewFrac = (idx < displayPts - 1)
float binNext = (idx < displayPts - 1) ? viewLo + static_cast<float>(idx + 1) / (displayPts - 1) * (viewHi - viewLo)
? static_cast<float>(idx + 1) / (displayPts - 1) * (bins - 1) : viewFrac;
: binF; float prevBinF = screenFracToBinFrac(prevViewFrac, freqScale, isIQ) * (bins - 1);
int b0 = static_cast<int>((binPrev + binF) * 0.5f); float nextBinF = screenFracToBinFrac(nextViewFrac, freqScale, isIQ) * (bins - 1);
int b1 = static_cast<int>((binF + binNext) * 0.5f);
int b0 = static_cast<int>((prevBinF + binF) * 0.5f);
int b1 = static_cast<int>((binF + nextBinF) * 0.5f);
b0 = std::clamp(b0, 0, bins - 1); b0 = std::clamp(b0, 0, bins - 1);
b1 = std::clamp(b1, b0, bins - 1); b1 = std::clamp(b1, b0, bins - 1);
@@ -53,7 +75,7 @@ static void buildPolyline(const std::vector<float>& spectrumDB,
for (int b = b0 + 1; b <= b1; ++b) for (int b = b0 + 1; b <= b1; ++b)
peakDB = std::max(peakDB, spectrumDB[b]); peakDB = std::max(peakDB, spectrumDB[b]);
float x = posX + xFrac * sizeX; float x = posX + screenFrac * sizeX;
float dbNorm = std::clamp((peakDB - minDB) / (maxDB - minDB), 0.0f, 1.0f); float dbNorm = std::clamp((peakDB - minDB) / (maxDB - minDB), 0.0f, 1.0f);
float y = posY + sizeY * (1.0f - dbNorm); float y = posY + sizeY * (1.0f - dbNorm);
outPoints[idx] = {x, y}; outPoints[idx] = {x, y};
@@ -66,12 +88,19 @@ void SpectrumDisplay::draw(const std::vector<std::vector<float>>& spectra,
double sampleRate, bool isIQ, double sampleRate, bool isIQ,
FreqScale freqScale, FreqScale freqScale,
float posX, float posY, float posX, float posY,
float sizeX, float sizeY) const { float sizeX, float sizeY,
float viewLo, float viewHi) const {
if (spectra.empty() || spectra[0].empty() || sizeX <= 0 || sizeY <= 0) return; if (spectra.empty() || spectra[0].empty() || sizeX <= 0 || sizeY <= 0) return;
ImDrawList* dl = ImGui::GetWindowDrawList(); ImDrawList* dl = ImGui::GetWindowDrawList();
double freqMin = isIQ ? -sampleRate / 2.0 : 0.0; double freqFullMin = isIQ ? -sampleRate / 2.0 : 0.0;
double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0; double freqFullMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0;
// Helper to convert a view fraction to frequency.
auto viewFracToFreq = [&](float vf) -> double {
float binFrac = screenFracToBinFrac(vf, freqScale, isIQ);
return freqFullMin + binFrac * (freqFullMax - freqFullMin);
};
// Background // Background
dl->AddRectFilled({posX, posY}, {posX + sizeX, posY + sizeY}, dl->AddRectFilled({posX, posY}, {posX + sizeX, posY + sizeY},
@@ -82,42 +111,43 @@ void SpectrumDisplay::draw(const std::vector<std::vector<float>>& spectra,
ImU32 gridCol = IM_COL32(60, 60, 80, 128); ImU32 gridCol = IM_COL32(60, 60, 80, 128);
ImU32 textCol = IM_COL32(180, 180, 200, 200); ImU32 textCol = IM_COL32(180, 180, 200, 200);
// ── Horizontal (dB) grid — adapt step to available height ──
constexpr float kMinPixPerHLine = 40.0f; // minimum pixels between labels
float dbRange = maxDB - minDB;
// Pick a nice step: 5, 10, 20, 50, ...
float dbStep = 10.0f; float dbStep = 10.0f;
static const float niceSteps[] = {5.0f, 10.0f, 20.0f, 50.0f, 100.0f};
for (float s : niceSteps) {
float pixPerStep = sizeY * s / dbRange;
if (pixPerStep >= kMinPixPerHLine) { dbStep = s; break; }
}
for (float db = std::ceil(minDB / dbStep) * dbStep; db <= maxDB; db += dbStep) { for (float db = std::ceil(minDB / dbStep) * dbStep; db <= maxDB; db += dbStep) {
float y = posY + sizeY * (1.0f - (db - minDB) / (maxDB - minDB)); float y = posY + sizeY * (1.0f - (db - minDB) / (maxDB - minDB));
dl->AddLine({posX, y}, {posX + sizeX, y}, gridCol); dl->AddLine({posX, y}, {posX + sizeX, y}, gridCol);
char label[32]; char label[16];
std::snprintf(label, sizeof(label), "%.0f dB", db); std::snprintf(label, sizeof(label), "%.0f", db);
dl->AddText({posX + 2, y - 12}, textCol, label); dl->AddText({posX + 2, y - 12}, textCol, label);
} }
int numVLines = 8; // ── Vertical (frequency) grid — adapt count to available width ──
constexpr float kMinPixPerVLine = 80.0f;
int numVLines = std::max(2, static_cast<int>(sizeX / kMinPixPerVLine));
for (int i = 0; i <= numVLines; ++i) { for (int i = 0; i <= numVLines; ++i) {
float frac = static_cast<float>(i) / numVLines; float frac = static_cast<float>(i) / numVLines;
double freq; float vf = viewLo + frac * (viewHi - viewLo);
float screenFrac; double freq = viewFracToFreq(vf);
float x = posX + frac * sizeX;
if (freqScale == FreqScale::Linear) {
freq = freqMin + frac * (freqMax - freqMin);
screenFrac = frac;
} else {
double logMinF = std::max(freqMin, 1.0);
double logMaxF = freqMax;
freq = std::pow(10.0, std::log10(logMinF) +
frac * (std::log10(logMaxF) - std::log10(logMinF)));
screenFrac = frac;
}
float x = posX + screenFrac * sizeX;
dl->AddLine({x, posY}, {x, posY + sizeY}, gridCol); dl->AddLine({x, posY}, {x, posY + sizeY}, gridCol);
char label[32]; char label[32];
if (std::abs(freq) >= 1e6) if (std::abs(freq) >= 1e6)
std::snprintf(label, sizeof(label), "%.2f MHz", freq / 1e6); std::snprintf(label, sizeof(label), "%.2fM", freq / 1e6);
else if (std::abs(freq) >= 1e3) else if (std::abs(freq) >= 1e3)
std::snprintf(label, sizeof(label), "%.1f kHz", freq / 1e3); std::snprintf(label, sizeof(label), "%.1fk", freq / 1e3);
else else
std::snprintf(label, sizeof(label), "%.0f Hz", freq); std::snprintf(label, sizeof(label), "%.0f", freq);
dl->AddText({x + 2, posY + sizeY - 14}, textCol, label); dl->AddText({x + 2, posY + sizeY - 14}, textCol, label);
} }
} }
@@ -131,8 +161,9 @@ void SpectrumDisplay::draw(const std::vector<std::vector<float>>& spectra,
? styles[ch] ? styles[ch]
: styles.back(); : styles.back();
buildPolyline(spectra[ch], minDB, maxDB, freqMin, freqMax, buildPolyline(spectra[ch], minDB, maxDB,
isIQ, freqScale, posX, posY, sizeX, sizeY, points); isIQ, freqScale, posX, posY, sizeX, sizeY,
viewLo, viewHi, points);
// Fill // Fill
if (fillSpectrum && points.size() >= 2) { if (fillSpectrum && points.size() >= 2) {
@@ -151,6 +182,26 @@ void SpectrumDisplay::draw(const std::vector<std::vector<float>>& spectra,
st.lineColor, ImDrawFlags_None, 1.5f); st.lineColor, ImDrawFlags_None, 1.5f);
} }
// Peak hold traces (drawn as dashed-style thin lines above the live spectrum).
if (peakHoldEnable && !peakHold_.empty()) {
for (int ch = 0; ch < nCh && ch < static_cast<int>(peakHold_.size()); ++ch) {
if (peakHold_[ch].empty()) continue;
const ChannelStyle& st = (ch < static_cast<int>(styles.size()))
? styles[ch] : styles.back();
// Use the same line color but dimmer.
ImU32 col = (st.lineColor & 0x00FFFFFF) | 0x90000000;
buildPolyline(peakHold_[ch], minDB, maxDB,
isIQ, freqScale, posX, posY, sizeX, sizeY,
viewLo, viewHi, points);
if (points.size() >= 2)
dl->AddPolyline(points.data(), static_cast<int>(points.size()),
col, ImDrawFlags_None, 1.0f);
}
}
// Border // Border
dl->AddRect({posX, posY}, {posX + sizeX, posY + sizeY}, dl->AddRect({posX, posY}, {posX + sizeX, posY + sizeY},
IM_COL32(100, 100, 120, 200)); IM_COL32(100, 100, 120, 200));
@@ -172,34 +223,33 @@ void SpectrumDisplay::draw(const std::vector<float>& spectrumDB,
double SpectrumDisplay::screenXToFreq(float screenX, float posX, float sizeX, double SpectrumDisplay::screenXToFreq(float screenX, float posX, float sizeX,
double sampleRate, bool isIQ, double sampleRate, bool isIQ,
FreqScale freqScale) const { FreqScale freqScale,
float frac = (screenX - posX) / sizeX; float viewLo, float viewHi) const {
frac = std::clamp(frac, 0.0f, 1.0f); float screenFrac = std::clamp((screenX - posX) / sizeX, 0.0f, 1.0f);
// Map screen fraction to view fraction
float viewFrac = viewLo + screenFrac * (viewHi - viewLo);
// Map view fraction to bin fraction (undo log if needed)
float binFrac = screenFracToBinFrac(viewFrac, freqScale, isIQ);
double freqMin = isIQ ? -sampleRate / 2.0 : 0.0; double freqMin = isIQ ? -sampleRate / 2.0 : 0.0;
double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0; double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0;
return freqMin + binFrac * (freqMax - freqMin);
if (freqScale == FreqScale::Logarithmic && !isIQ) {
double logMin = std::log10(std::max(freqMin, 1.0));
double logMax = std::log10(freqMax);
return std::pow(10.0, logMin + frac * (logMax - logMin));
}
return freqMin + frac * (freqMax - freqMin);
} }
float SpectrumDisplay::freqToScreenX(double freq, float posX, float sizeX, float SpectrumDisplay::freqToScreenX(double freq, float posX, float sizeX,
double sampleRate, bool isIQ, double sampleRate, bool isIQ,
FreqScale freqScale) const { FreqScale freqScale,
float viewLo, float viewHi) const {
double freqMin = isIQ ? -sampleRate / 2.0 : 0.0; double freqMin = isIQ ? -sampleRate / 2.0 : 0.0;
double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0; double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0;
float frac; // Freq → bin fraction
if (freqScale == FreqScale::Logarithmic && !isIQ) { float binFrac = static_cast<float>((freq - freqMin) / (freqMax - freqMin));
frac = freqToLogFrac(freq, std::max(freqMin, 1.0), freqMax); // Bin fraction → full-range screen fraction (apply log inverse)
} else { float viewFrac = binFracToScreenFrac(binFrac, freqScale, isIQ);
frac = static_cast<float>((freq - freqMin) / (freqMax - freqMin)); // View fraction → screen fraction
} float screenFrac = (viewFrac - viewLo) / (viewHi - viewLo);
return posX + frac * sizeX; return posX + screenFrac * sizeX;
} }
float SpectrumDisplay::screenYToDB(float screenY, float posY, float sizeY, float SpectrumDisplay::screenYToDB(float screenY, float posY, float sizeY,
@@ -209,4 +259,41 @@ float SpectrumDisplay::screenYToDB(float screenY, float posY, float sizeY,
return minDB + frac * (maxDB - minDB); return minDB + frac * (maxDB - minDB);
} }
void SpectrumDisplay::updatePeakHold(const std::vector<std::vector<float>>& spectra) {
if (!peakHoldEnable) return;
int nCh = static_cast<int>(spectra.size());
// Grow/shrink channel count.
if (static_cast<int>(peakHold_.size()) != nCh) {
peakHold_.resize(nCh);
}
for (int ch = 0; ch < nCh; ++ch) {
int bins = static_cast<int>(spectra[ch].size());
if (bins == 0) continue;
// Reset if bin count changed.
if (static_cast<int>(peakHold_[ch].size()) != bins)
peakHold_[ch].assign(bins, -200.0f);
float dt = ImGui::GetIO().DeltaTime; // seconds since last frame
float decayThisFrame = peakHoldDecay * dt;
for (int i = 0; i < bins; ++i) {
if (spectra[ch][i] >= peakHold_[ch][i]) {
peakHold_[ch][i] = spectra[ch][i];
} else {
peakHold_[ch][i] -= decayThisFrame;
if (peakHold_[ch][i] < spectra[ch][i])
peakHold_[ch][i] = spectra[ch][i];
}
}
}
}
void SpectrumDisplay::clearPeakHold() {
peakHold_.clear();
}
} // namespace baudline } // namespace baudline

View File

@@ -15,12 +15,14 @@ class SpectrumDisplay {
public: public:
// Draw multiple channel spectra overlaid. // Draw multiple channel spectra overlaid.
// `spectra` has one entry per channel; `styles` has matching colors. // `spectra` has one entry per channel; `styles` has matching colors.
// viewLo/viewHi (01) control the visible frequency range (zoom/pan).
void draw(const std::vector<std::vector<float>>& spectra, void draw(const std::vector<std::vector<float>>& spectra,
const std::vector<ChannelStyle>& styles, const std::vector<ChannelStyle>& styles,
float minDB, float maxDB, float minDB, float maxDB,
double sampleRate, bool isIQ, double sampleRate, bool isIQ,
FreqScale freqScale, FreqScale freqScale,
float posX, float posY, float sizeX, float sizeY) const; float posX, float posY, float sizeX, float sizeY,
float viewLo = 0.0f, float viewHi = 1.0f) const;
// Convenience: single-channel draw (backward compat). // Convenience: single-channel draw (backward compat).
void draw(const std::vector<float>& spectrumDB, void draw(const std::vector<float>& spectrumDB,
@@ -30,14 +32,26 @@ public:
float posX, float posY, float sizeX, float sizeY) const; float posX, float posY, float sizeX, float sizeY) const;
double screenXToFreq(float screenX, float posX, float sizeX, double screenXToFreq(float screenX, float posX, float sizeX,
double sampleRate, bool isIQ, FreqScale freqScale) const; double sampleRate, bool isIQ, FreqScale freqScale,
float viewLo = 0.0f, float viewHi = 1.0f) const;
float freqToScreenX(double freq, float posX, float sizeX, float freqToScreenX(double freq, float posX, float sizeX,
double sampleRate, bool isIQ, FreqScale freqScale) const; double sampleRate, bool isIQ, FreqScale freqScale,
float viewLo = 0.0f, float viewHi = 1.0f) const;
float screenYToDB(float screenY, float posY, float sizeY, float screenYToDB(float screenY, float posY, float sizeY,
float minDB, float maxDB) const; float minDB, float maxDB) const;
// Peak hold: update with current spectra, then draw the held peaks.
void updatePeakHold(const std::vector<std::vector<float>>& spectra);
void clearPeakHold();
bool showGrid = true; bool showGrid = true;
bool fillSpectrum = false; bool fillSpectrum = false;
bool peakHoldEnable = false;
float peakHoldDecay = 20.0f; // dB/second decay rate
private:
// One peak-hold trace per channel.
mutable std::vector<std::vector<float>> peakHold_;
}; };
} // namespace baudline } // namespace baudline

View File

@@ -11,12 +11,12 @@ WaterfallDisplay::~WaterfallDisplay() {
if (texture_) glDeleteTextures(1, &texture_); if (texture_) glDeleteTextures(1, &texture_);
} }
void WaterfallDisplay::init(int width, int height) { void WaterfallDisplay::init(int binCount, int height) {
width_ = width; width_ = binCount;
height_ = height; height_ = height;
currentRow_ = height_ - 1; currentRow_ = height_ - 1;
pixelBuf_.resize(width_ * height_ * 3, 0); pixelBuf_.assign(width_ * height_ * 3, 0);
if (texture_) glDeleteTextures(1, &texture_); if (texture_) glDeleteTextures(1, &texture_);
glGenTextures(1, &texture_); glGenTextures(1, &texture_);
@@ -30,17 +30,9 @@ void WaterfallDisplay::init(int width, int height) {
glBindTexture(GL_TEXTURE_2D, 0); glBindTexture(GL_TEXTURE_2D, 0);
} }
void WaterfallDisplay::resize(int width, int height) { void WaterfallDisplay::resize(int binCount, int height) {
if (width == width_ && height == height_) return; if (binCount == width_ && height == height_) return;
init(width, height); init(binCount, height);
}
float WaterfallDisplay::sampleBin(const std::vector<float>& spec, float binF) {
int bins = static_cast<int>(spec.size());
int b0 = static_cast<int>(binF);
int b1 = std::min(b0 + 1, bins - 1);
float t = binF - b0;
return spec[b0] * (1.0f - t) + spec[b1] * t;
} }
void WaterfallDisplay::advanceRow() { void WaterfallDisplay::advanceRow() {
@@ -57,9 +49,9 @@ void WaterfallDisplay::pushLine(const std::vector<float>& spectrumDB,
int row = currentRow_; int row = currentRow_;
int rowOffset = row * width_ * 3; int rowOffset = row * width_ * 3;
// One texel per bin — direct 1:1 mapping.
for (int x = 0; x < width_; ++x) { for (int x = 0; x < width_; ++x) {
float frac = static_cast<float>(x) / (width_ - 1); float dB = (x < bins) ? spectrumDB[x] : -200.0f;
float dB = sampleBin(spectrumDB, frac * (bins - 1));
Color3 c = colorMap_.mapDB(dB, minDB, maxDB); Color3 c = colorMap_.mapDB(dB, minDB, maxDB);
pixelBuf_[rowOffset + x * 3 + 0] = c.r; pixelBuf_[rowOffset + x * 3 + 0] = c.r;
@@ -85,10 +77,8 @@ void WaterfallDisplay::pushLineMulti(
float range = maxDB - minDB; float range = maxDB - minDB;
if (range < 1.0f) range = 1.0f; if (range < 1.0f) range = 1.0f;
// One texel per bin — direct 1:1 mapping.
for (int x = 0; x < width_; ++x) { for (int x = 0; x < width_; ++x) {
float frac = static_cast<float>(x) / (width_ - 1);
// Accumulate color contributions from each enabled channel.
float accR = 0.0f, accG = 0.0f, accB = 0.0f; float accR = 0.0f, accG = 0.0f, accB = 0.0f;
for (int ch = 0; ch < nCh; ++ch) { for (int ch = 0; ch < nCh; ++ch) {
@@ -97,7 +87,7 @@ void WaterfallDisplay::pushLineMulti(
if (channelSpectra[ch].empty()) continue; if (channelSpectra[ch].empty()) continue;
int bins = static_cast<int>(channelSpectra[ch].size()); int bins = static_cast<int>(channelSpectra[ch].size());
float dB = sampleBin(channelSpectra[ch], frac * (bins - 1)); float dB = (x < bins) ? channelSpectra[ch][x] : -200.0f;
float intensity = std::clamp((dB - minDB) / range, 0.0f, 1.0f); float intensity = std::clamp((dB - minDB) / range, 0.0f, 1.0f);
accR += channels[ch].r * intensity; accR += channels[ch].r * intensity;

View File

@@ -8,9 +8,8 @@
namespace baudline { namespace baudline {
// Per-channel color + enable flag for multi-channel waterfall mode.
struct WaterfallChannelInfo { struct WaterfallChannelInfo {
float r, g, b; // channel color [0,1] float r, g, b;
bool enabled; bool enabled;
}; };
@@ -19,14 +18,14 @@ public:
WaterfallDisplay(); WaterfallDisplay();
~WaterfallDisplay(); ~WaterfallDisplay();
// Initialize OpenGL texture. Call after GL context is ready. // Initialize with bin-resolution width and history height.
void init(int width, int height); // width = number of FFT bins (spectrum size), height = history rows.
void init(int binCount, int height);
// Single-channel mode: colormap-based. // Single-channel colormap mode. One texel per bin — no frequency remapping.
void pushLine(const std::vector<float>& spectrumDB, float minDB, float maxDB); void pushLine(const std::vector<float>& spectrumDB, float minDB, float maxDB);
// Multi-channel overlay mode: each channel is rendered in its own color, // Multi-channel overlay mode. One texel per bin.
// intensity proportional to signal level. Colors are additively blended.
void pushLineMulti(const std::vector<std::vector<float>>& channelSpectra, void pushLineMulti(const std::vector<std::vector<float>>& channelSpectra,
const std::vector<WaterfallChannelInfo>& channels, const std::vector<WaterfallChannelInfo>& channels,
float minDB, float maxDB); float minDB, float maxDB);
@@ -36,22 +35,13 @@ public:
int height() const { return height_; } int height() const { return height_; }
int currentRow() const { return currentRow_; } int currentRow() const { return currentRow_; }
void resize(int width, int height); void resize(int binCount, int height);
void setColorMap(const ColorMap& cm) { colorMap_ = cm; } void setColorMap(const ColorMap& cm) { colorMap_ = cm; }
float zoomX = 1.0f;
float zoomY = 1.0f;
float scrollX = 0.0f;
float scrollY = 0.0f;
private: private:
void uploadRow(int row); void uploadRow(int row);
void advanceRow(); void advanceRow();
// Interpolate a dB value at a fractional bin position.
static float sampleBin(const std::vector<float>& spec, float binF);
GLuint texture_ = 0; GLuint texture_ = 0;
int width_ = 0; int width_ = 0;
int height_ = 0; int height_ = 0;