commit no. 3
This commit is contained in:
@@ -103,7 +103,6 @@ struct AnalyzerSettings {
|
||||
bool isIQ = false; // true → complex input (2-ch interleaved)
|
||||
int numChannels = 1; // real channels (ignored when isIQ)
|
||||
double sampleRate = 48000.0;
|
||||
int averaging = 1; // number of spectra to average (1 = none)
|
||||
|
||||
// Effective input channel count (for buffer sizing / deinterleaving).
|
||||
int inputChannels() const { return isIQ ? 2 : numChannels; }
|
||||
|
||||
@@ -37,9 +37,6 @@ void SpectrumAnalyzer::configure(const AnalyzerSettings& settings) {
|
||||
channelComplex_.assign(nSpec, std::vector<std::complex<float>>(specSz, {0,0}));
|
||||
channelWaterfalls_.assign(nSpec, {});
|
||||
|
||||
avgAccum_.assign(nSpec, std::vector<float>(specSz, 0.0f));
|
||||
avgCount_ = 0;
|
||||
|
||||
newSpectrumReady_ = false;
|
||||
}
|
||||
}
|
||||
@@ -105,28 +102,6 @@ void SpectrumAnalyzer::processBlock() {
|
||||
for (float& v : db)
|
||||
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) {
|
||||
channelSpectra_[ch] = tempDBs[ch];
|
||||
channelComplex_[ch] = tempCplx[ch];
|
||||
|
||||
@@ -58,10 +58,6 @@ private:
|
||||
size_t accumPos_ = 0;
|
||||
size_t hopSize_ = 0;
|
||||
|
||||
// Per-channel averaging
|
||||
std::vector<std::vector<float>> avgAccum_;
|
||||
int avgCount_ = 0;
|
||||
|
||||
// Per-channel output: magnitude (dB) and complex
|
||||
std::vector<std::vector<float>> channelSpectra_;
|
||||
std::vector<std::vector<std::complex<float>>> channelComplex_;
|
||||
|
||||
@@ -270,13 +270,69 @@ void Application::render() {
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
// Spectrum + Waterfall
|
||||
// Spectrum + Waterfall with draggable splitter
|
||||
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();
|
||||
renderWaterfallPanel();
|
||||
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();
|
||||
|
||||
// ── 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::End();
|
||||
@@ -356,10 +412,33 @@ void Application::renderControlPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Overlap
|
||||
if (ImGui::SliderFloat("Overlap", &overlapPct_, 0.0f, 95.0f, "%.1f%%")) {
|
||||
settings_.overlap = overlapPct_ / 100.0f;
|
||||
updateAnalyzerSettings();
|
||||
// Overlap — inverted x⁴ curve: sensitive at the high end (90%+).
|
||||
{
|
||||
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;
|
||||
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
|
||||
@@ -382,9 +461,6 @@ void Application::renderControlPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Averaging
|
||||
ImGui::SliderInt("Averaging", &settings_.averaging, 1, 32);
|
||||
|
||||
ImGui::Separator();
|
||||
ImGui::Text("Display");
|
||||
|
||||
@@ -406,10 +482,34 @@ void Application::renderControlPanel() {
|
||||
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
|
||||
ImGui::DragFloatRange2("dB Range", &minDB_, &maxDB_, 1.0f, -200.0f, 20.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)
|
||||
int nCh = analyzer_.numSpectra();
|
||||
if (nCh > 1) {
|
||||
@@ -486,7 +586,11 @@ void Application::renderControlPanel() {
|
||||
|
||||
void Application::renderSpectrumPanel() {
|
||||
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();
|
||||
specPosX_ = pos.x;
|
||||
@@ -526,12 +630,15 @@ void Application::renderSpectrumPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
specDisplay_.updatePeakHold(allSpectra);
|
||||
specDisplay_.draw(allSpectra, styles, minDB_, maxDB_,
|
||||
settings_.sampleRate, settings_.isIQ, freqScale_,
|
||||
specPosX_, specPosY_, specSizeX_, specSizeY_);
|
||||
specPosX_, specPosY_, specSizeX_, specSizeY_,
|
||||
viewLo_, viewHi_);
|
||||
|
||||
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_);
|
||||
|
||||
@@ -542,64 +649,113 @@ void Application::renderWaterfallPanel() {
|
||||
float availW = ImGui::GetContentRegionAvail().x;
|
||||
float availH = ImGui::GetContentRegionAvail().y;
|
||||
|
||||
int newW = static_cast<int>(availW);
|
||||
int newH = static_cast<int>(availH);
|
||||
if (newW < 1) newW = 1;
|
||||
if (newH < 1) newH = 1;
|
||||
// Fixed history depth — independent of screen height, so resizing the
|
||||
// splitter doesn't recreate the texture every frame.
|
||||
constexpr int kHistoryRows = 1024;
|
||||
|
||||
if (newW != waterfallW_ || newH != waterfallH_) {
|
||||
waterfallW_ = newW;
|
||||
waterfallH_ = newH;
|
||||
waterfall_.resize(waterfallW_, waterfallH_);
|
||||
// Only recreate when the bin count (FFT size) changes.
|
||||
int binCount = std::max(1, analyzer_.spectrumSize());
|
||||
if (binCount != waterfall_.width() || waterfall_.height() != kHistoryRows) {
|
||||
waterfall_.resize(binCount, kHistoryRows);
|
||||
waterfall_.setColorMap(colorMap_);
|
||||
}
|
||||
|
||||
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();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
auto texID = static_cast<ImTextureID>(waterfall_.textureID());
|
||||
|
||||
int h = waterfall_.height();
|
||||
int cur = (waterfall_.currentRow() + 1) % h;
|
||||
float splitFrac = static_cast<float>(h - cur) / h;
|
||||
// The newest row was just written at currentRow()+1 (mod h) — but
|
||||
// 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)
|
||||
float topH = availH * splitFrac;
|
||||
dl->AddImage(texID,
|
||||
{pos.x, pos.y},
|
||||
{pos.x + availW, pos.y + topH},
|
||||
{0.0f, static_cast<float>(cur) / h},
|
||||
{1.0f, 1.0f});
|
||||
// Newest row index in the circular buffer.
|
||||
int newestRow = (waterfall_.currentRow() + 1) % h;
|
||||
|
||||
// Bottom part: rows from 0 to cur-1 (newest)
|
||||
if (cur > 0) {
|
||||
dl->AddImage(texID,
|
||||
{pos.x, pos.y + topH},
|
||||
{pos.x + availW, pos.y + availH},
|
||||
{0.0f, 0.0f},
|
||||
{1.0f, static_cast<float>(cur) / h});
|
||||
// Render 1:1 (one texture row = one screen pixel), top-aligned,
|
||||
// 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,
|
||||
{pos.x, yStart},
|
||||
{pos.x + availW, yStart + spanH},
|
||||
{viewLo_, v0}, {viewHi_, v1});
|
||||
} 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);
|
||||
double freqMin = settings_.isIQ ? -settings_.sampleRate / 2.0 : 0.0;
|
||||
double freqMax = settings_.isIQ ? settings_.sampleRate / 2.0 : settings_.sampleRate / 2.0;
|
||||
double freqFullMin = settings_.isIQ ? -settings_.sampleRate / 2.0 : 0.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;
|
||||
for (int i = 0; i <= numLabels; ++i) {
|
||||
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;
|
||||
|
||||
char label[32];
|
||||
@@ -612,6 +768,75 @@ void Application::renderWaterfallPanel() {
|
||||
|
||||
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});
|
||||
@@ -630,7 +855,8 @@ void Application::handleSpectrumInput(float posX, float posY,
|
||||
// Update hover cursor
|
||||
double freq = specDisplay_.screenXToFreq(mx, posX, sizeX,
|
||||
settings_.sampleRate,
|
||||
settings_.isIQ, freqScale_);
|
||||
settings_.isIQ, freqScale_,
|
||||
viewLo_, viewHi_);
|
||||
float dB = specDisplay_.screenYToDB(my, posY, sizeY, minDB_, maxDB_);
|
||||
|
||||
// Find closest bin
|
||||
@@ -648,27 +874,65 @@ void Application::handleSpectrumInput(float posX, float posY,
|
||||
}
|
||||
|
||||
// Left click: cursor A
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !io.WantCaptureMouse) {
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
int peakBin = cursors_.findLocalPeak(spec, bin, 10);
|
||||
double peakFreq = analyzer_.binToFreq(peakBin);
|
||||
cursors_.setCursorA(peakFreq, spec[peakBin], peakBin);
|
||||
}
|
||||
// Right click: cursor B
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !io.WantCaptureMouse) {
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
|
||||
int peakBin = cursors_.findLocalPeak(spec, bin, 10);
|
||||
double peakFreq = analyzer_.binToFreq(peakBin);
|
||||
cursors_.setCursorB(peakFreq, spec[peakBin], peakBin);
|
||||
}
|
||||
|
||||
// Scroll: zoom dB range
|
||||
if (io.MouseWheel != 0 && !io.WantCaptureMouse) {
|
||||
float zoom = io.MouseWheel * 5.0f;
|
||||
minDB_ += zoom;
|
||||
maxDB_ -= zoom;
|
||||
if (maxDB_ - minDB_ < 10.0f) {
|
||||
float mid = (minDB_ + maxDB_) / 2.0f;
|
||||
minDB_ = mid - 5.0f;
|
||||
maxDB_ = mid + 5.0f;
|
||||
{
|
||||
// Ctrl+Scroll or Shift+Scroll: zoom dB range
|
||||
if (io.MouseWheel != 0 && (io.KeyCtrl || io.KeyShift)) {
|
||||
float zoom = io.MouseWheel * 5.0f;
|
||||
minDB_ += zoom;
|
||||
maxDB_ -= zoom;
|
||||
if (maxDB_ - minDB_ < 10.0f) {
|
||||
float mid = (minDB_ + maxDB_) / 2.0f;
|
||||
minDB_ = 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 {
|
||||
@@ -747,8 +1011,9 @@ void Application::updateAnalyzerSettings() {
|
||||
|
||||
// Re-init waterfall texture so the old image from a different FFT
|
||||
// size doesn't persist.
|
||||
if (waterfallW_ > 0 && waterfallH_ > 0)
|
||||
waterfall_.init(waterfallW_, waterfallH_);
|
||||
constexpr int kHistoryRows = 1024;
|
||||
int binCount2 = std::max(1, analyzer_.spectrumSize());
|
||||
waterfall_.init(binCount2, kHistoryRows);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,8 +113,8 @@ private:
|
||||
float maxDB_ = 0.0f;
|
||||
FreqScale freqScale_ = FreqScale::Linear;
|
||||
bool paused_ = false;
|
||||
int waterfallW_ = 0;
|
||||
int waterfallH_ = 0;
|
||||
// (waterfallW_ removed — texture width tracks bin count automatically)
|
||||
// (waterfallH_ removed — fixed history depth of 1024 rows)
|
||||
|
||||
// FFT size options
|
||||
static constexpr int kFFTSizes[] = {256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536};
|
||||
@@ -159,8 +159,17 @@ private:
|
||||
std::vector<MathChannel> mathChannels_;
|
||||
std::vector<std::vector<float>> mathSpectra_; // computed each frame
|
||||
|
||||
// Spectrum panel geometry (stored for cursor interaction)
|
||||
// Frequency zoom/pan (normalized 0–1 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 wfPosX_ = 0, wfPosY_ = 0, wfSizeX_ = 0, wfSizeY_ = 0;
|
||||
};
|
||||
|
||||
} // namespace baudline
|
||||
|
||||
@@ -29,13 +29,15 @@ void Cursors::update(const std::vector<float>& spectrumDB,
|
||||
void Cursors::draw(const SpectrumDisplay& specDisplay,
|
||||
float posX, float posY, float sizeX, float sizeY,
|
||||
double sampleRate, bool isIQ, FreqScale freqScale,
|
||||
float minDB, float maxDB) const {
|
||||
float minDB, float maxDB,
|
||||
float viewLo, float viewHi) const {
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
|
||||
auto drawCursor = [&](const CursorInfo& c, ImU32 color, const char* label) {
|
||||
if (!c.active) return;
|
||||
float x = specDisplay.freqToScreenX(c.freq, posX, sizeX,
|
||||
sampleRate, isIQ, freqScale);
|
||||
sampleRate, isIQ, freqScale,
|
||||
viewLo, viewHi);
|
||||
float dbNorm = (c.dB - minDB) / (maxDB - minDB);
|
||||
dbNorm = std::clamp(dbNorm, 0.0f, 1.0f);
|
||||
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);
|
||||
}
|
||||
|
||||
// Hover cursor
|
||||
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);
|
||||
}
|
||||
// (Hover cursor line is drawn cross-panel by Application.)
|
||||
}
|
||||
|
||||
void Cursors::drawPanel() const {
|
||||
|
||||
@@ -23,7 +23,8 @@ public:
|
||||
void draw(const SpectrumDisplay& specDisplay,
|
||||
float posX, float posY, float sizeX, float sizeY,
|
||||
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).
|
||||
void drawPanel() const;
|
||||
|
||||
@@ -12,12 +12,35 @@ static float freqToLogFrac(double freq, double minFreq, double maxFreq) {
|
||||
return static_cast<float>((logF - logMin) / (logMax - logMin));
|
||||
}
|
||||
|
||||
// Build a decimated polyline for one spectrum.
|
||||
// Map a "full-range screen fraction" (0–1) 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,
|
||||
float minDB, float maxDB,
|
||||
double freqMin, double freqMax,
|
||||
bool isIQ, FreqScale freqScale,
|
||||
float posX, float posY, float sizeX, float sizeY,
|
||||
float viewLo, float viewHi,
|
||||
std::vector<ImVec2>& outPoints) {
|
||||
int bins = static_cast<int>(spectrumDB.size());
|
||||
int displayPts = std::min(bins, static_cast<int>(sizeX));
|
||||
@@ -25,27 +48,26 @@ static void buildPolyline(const std::vector<float>& spectrumDB,
|
||||
|
||||
outPoints.resize(displayPts);
|
||||
for (int idx = 0; idx < displayPts; ++idx) {
|
||||
float frac = static_cast<float>(idx) / (displayPts - 1);
|
||||
float xFrac;
|
||||
float screenFrac = static_cast<float>(idx) / (displayPts - 1);
|
||||
// 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) {
|
||||
double freq = frac * (freqMax - freqMin) + freqMin;
|
||||
double logMin = std::max(freqMin, 1.0);
|
||||
xFrac = freqToLogFrac(freq, logMin, freqMax);
|
||||
} else {
|
||||
xFrac = frac;
|
||||
}
|
||||
float binF = binFrac * (bins - 1);
|
||||
|
||||
// Bucket range for peak-hold decimation.
|
||||
float binF = frac * (bins - 1);
|
||||
float binPrev = (idx > 0)
|
||||
? static_cast<float>(idx - 1) / (displayPts - 1) * (bins - 1)
|
||||
: binF;
|
||||
float binNext = (idx < displayPts - 1)
|
||||
? static_cast<float>(idx + 1) / (displayPts - 1) * (bins - 1)
|
||||
: binF;
|
||||
int b0 = static_cast<int>((binPrev + binF) * 0.5f);
|
||||
int b1 = static_cast<int>((binF + binNext) * 0.5f);
|
||||
float prevViewFrac = (idx > 0)
|
||||
? viewLo + static_cast<float>(idx - 1) / (displayPts - 1) * (viewHi - viewLo)
|
||||
: viewFrac;
|
||||
float nextViewFrac = (idx < displayPts - 1)
|
||||
? viewLo + static_cast<float>(idx + 1) / (displayPts - 1) * (viewHi - viewLo)
|
||||
: viewFrac;
|
||||
float prevBinF = screenFracToBinFrac(prevViewFrac, freqScale, isIQ) * (bins - 1);
|
||||
float nextBinF = screenFracToBinFrac(nextViewFrac, freqScale, isIQ) * (bins - 1);
|
||||
|
||||
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);
|
||||
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)
|
||||
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 y = posY + sizeY * (1.0f - dbNorm);
|
||||
outPoints[idx] = {x, y};
|
||||
@@ -66,12 +88,19 @@ void SpectrumDisplay::draw(const std::vector<std::vector<float>>& spectra,
|
||||
double sampleRate, bool isIQ,
|
||||
FreqScale freqScale,
|
||||
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;
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
double freqMin = isIQ ? -sampleRate / 2.0 : 0.0;
|
||||
double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0;
|
||||
double freqFullMin = isIQ ? -sampleRate / 2.0 : 0.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
|
||||
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 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;
|
||||
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) {
|
||||
float y = posY + sizeY * (1.0f - (db - minDB) / (maxDB - minDB));
|
||||
dl->AddLine({posX, y}, {posX + sizeX, y}, gridCol);
|
||||
char label[32];
|
||||
std::snprintf(label, sizeof(label), "%.0f dB", db);
|
||||
char label[16];
|
||||
std::snprintf(label, sizeof(label), "%.0f", db);
|
||||
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) {
|
||||
float frac = static_cast<float>(i) / numVLines;
|
||||
double freq;
|
||||
float screenFrac;
|
||||
|
||||
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;
|
||||
float vf = viewLo + frac * (viewHi - viewLo);
|
||||
double freq = viewFracToFreq(vf);
|
||||
float x = posX + frac * sizeX;
|
||||
dl->AddLine({x, posY}, {x, posY + sizeY}, gridCol);
|
||||
|
||||
char label[32];
|
||||
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)
|
||||
std::snprintf(label, sizeof(label), "%.1f kHz", freq / 1e3);
|
||||
std::snprintf(label, sizeof(label), "%.1fk", freq / 1e3);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -131,8 +161,9 @@ void SpectrumDisplay::draw(const std::vector<std::vector<float>>& spectra,
|
||||
? styles[ch]
|
||||
: styles.back();
|
||||
|
||||
buildPolyline(spectra[ch], minDB, maxDB, freqMin, freqMax,
|
||||
isIQ, freqScale, posX, posY, sizeX, sizeY, points);
|
||||
buildPolyline(spectra[ch], minDB, maxDB,
|
||||
isIQ, freqScale, posX, posY, sizeX, sizeY,
|
||||
viewLo, viewHi, points);
|
||||
|
||||
// Fill
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
dl->AddRect({posX, posY}, {posX + sizeX, posY + sizeY},
|
||||
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 sampleRate, bool isIQ,
|
||||
FreqScale freqScale) const {
|
||||
float frac = (screenX - posX) / sizeX;
|
||||
frac = std::clamp(frac, 0.0f, 1.0f);
|
||||
FreqScale freqScale,
|
||||
float viewLo, float viewHi) const {
|
||||
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 freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0;
|
||||
|
||||
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);
|
||||
return freqMin + binFrac * (freqMax - freqMin);
|
||||
}
|
||||
|
||||
float SpectrumDisplay::freqToScreenX(double freq, float posX, float sizeX,
|
||||
double sampleRate, bool isIQ,
|
||||
FreqScale freqScale) const {
|
||||
FreqScale freqScale,
|
||||
float viewLo, float viewHi) const {
|
||||
double freqMin = isIQ ? -sampleRate / 2.0 : 0.0;
|
||||
double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0;
|
||||
|
||||
float frac;
|
||||
if (freqScale == FreqScale::Logarithmic && !isIQ) {
|
||||
frac = freqToLogFrac(freq, std::max(freqMin, 1.0), freqMax);
|
||||
} else {
|
||||
frac = static_cast<float>((freq - freqMin) / (freqMax - freqMin));
|
||||
}
|
||||
return posX + frac * sizeX;
|
||||
// Freq → bin fraction
|
||||
float binFrac = static_cast<float>((freq - freqMin) / (freqMax - freqMin));
|
||||
// Bin fraction → full-range screen fraction (apply log inverse)
|
||||
float viewFrac = binFracToScreenFrac(binFrac, freqScale, isIQ);
|
||||
// View fraction → screen fraction
|
||||
float screenFrac = (viewFrac - viewLo) / (viewHi - viewLo);
|
||||
return posX + screenFrac * sizeX;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -15,12 +15,14 @@ class SpectrumDisplay {
|
||||
public:
|
||||
// Draw multiple channel spectra overlaid.
|
||||
// `spectra` has one entry per channel; `styles` has matching colors.
|
||||
// viewLo/viewHi (0–1) control the visible frequency range (zoom/pan).
|
||||
void draw(const std::vector<std::vector<float>>& spectra,
|
||||
const std::vector<ChannelStyle>& styles,
|
||||
float minDB, float maxDB,
|
||||
double sampleRate, bool isIQ,
|
||||
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).
|
||||
void draw(const std::vector<float>& spectrumDB,
|
||||
@@ -30,14 +32,26 @@ public:
|
||||
float posX, float posY, float sizeX, float sizeY) const;
|
||||
|
||||
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,
|
||||
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 minDB, float maxDB) const;
|
||||
|
||||
bool showGrid = true;
|
||||
bool fillSpectrum = false;
|
||||
// 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 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
|
||||
|
||||
@@ -11,12 +11,12 @@ WaterfallDisplay::~WaterfallDisplay() {
|
||||
if (texture_) glDeleteTextures(1, &texture_);
|
||||
}
|
||||
|
||||
void WaterfallDisplay::init(int width, int height) {
|
||||
width_ = width;
|
||||
void WaterfallDisplay::init(int binCount, int height) {
|
||||
width_ = binCount;
|
||||
height_ = height;
|
||||
currentRow_ = height_ - 1;
|
||||
|
||||
pixelBuf_.resize(width_ * height_ * 3, 0);
|
||||
pixelBuf_.assign(width_ * height_ * 3, 0);
|
||||
|
||||
if (texture_) glDeleteTextures(1, &texture_);
|
||||
glGenTextures(1, &texture_);
|
||||
@@ -30,17 +30,9 @@ void WaterfallDisplay::init(int width, int height) {
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
}
|
||||
|
||||
void WaterfallDisplay::resize(int width, int height) {
|
||||
if (width == width_ && height == height_) return;
|
||||
init(width, 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::resize(int binCount, int height) {
|
||||
if (binCount == width_ && height == height_) return;
|
||||
init(binCount, height);
|
||||
}
|
||||
|
||||
void WaterfallDisplay::advanceRow() {
|
||||
@@ -57,9 +49,9 @@ void WaterfallDisplay::pushLine(const std::vector<float>& spectrumDB,
|
||||
int row = currentRow_;
|
||||
int rowOffset = row * width_ * 3;
|
||||
|
||||
// One texel per bin — direct 1:1 mapping.
|
||||
for (int x = 0; x < width_; ++x) {
|
||||
float frac = static_cast<float>(x) / (width_ - 1);
|
||||
float dB = sampleBin(spectrumDB, frac * (bins - 1));
|
||||
float dB = (x < bins) ? spectrumDB[x] : -200.0f;
|
||||
Color3 c = colorMap_.mapDB(dB, minDB, maxDB);
|
||||
|
||||
pixelBuf_[rowOffset + x * 3 + 0] = c.r;
|
||||
@@ -85,10 +77,8 @@ void WaterfallDisplay::pushLineMulti(
|
||||
float range = maxDB - minDB;
|
||||
if (range < 1.0f) range = 1.0f;
|
||||
|
||||
// One texel per bin — direct 1:1 mapping.
|
||||
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;
|
||||
|
||||
for (int ch = 0; ch < nCh; ++ch) {
|
||||
@@ -97,7 +87,7 @@ void WaterfallDisplay::pushLineMulti(
|
||||
if (channelSpectra[ch].empty()) continue;
|
||||
|
||||
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);
|
||||
|
||||
accR += channels[ch].r * intensity;
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
|
||||
namespace baudline {
|
||||
|
||||
// Per-channel color + enable flag for multi-channel waterfall mode.
|
||||
struct WaterfallChannelInfo {
|
||||
float r, g, b; // channel color [0,1]
|
||||
float r, g, b;
|
||||
bool enabled;
|
||||
};
|
||||
|
||||
@@ -19,14 +18,14 @@ public:
|
||||
WaterfallDisplay();
|
||||
~WaterfallDisplay();
|
||||
|
||||
// Initialize OpenGL texture. Call after GL context is ready.
|
||||
void init(int width, int height);
|
||||
// Initialize with bin-resolution width and history 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);
|
||||
|
||||
// Multi-channel overlay mode: each channel is rendered in its own color,
|
||||
// intensity proportional to signal level. Colors are additively blended.
|
||||
// Multi-channel overlay mode. One texel per bin.
|
||||
void pushLineMulti(const std::vector<std::vector<float>>& channelSpectra,
|
||||
const std::vector<WaterfallChannelInfo>& channels,
|
||||
float minDB, float maxDB);
|
||||
@@ -36,22 +35,13 @@ public:
|
||||
int height() const { return height_; }
|
||||
int currentRow() const { return currentRow_; }
|
||||
|
||||
void resize(int width, int height);
|
||||
|
||||
void resize(int binCount, int height);
|
||||
void setColorMap(const ColorMap& cm) { colorMap_ = cm; }
|
||||
|
||||
float zoomX = 1.0f;
|
||||
float zoomY = 1.0f;
|
||||
float scrollX = 0.0f;
|
||||
float scrollY = 0.0f;
|
||||
|
||||
private:
|
||||
void uploadRow(int row);
|
||||
void advanceRow();
|
||||
|
||||
// Interpolate a dB value at a fractional bin position.
|
||||
static float sampleBin(const std::vector<float>& spec, float binF);
|
||||
|
||||
GLuint texture_ = 0;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
|
||||
Reference in New Issue
Block a user