split off more stuff
This commit is contained in:
@@ -53,6 +53,8 @@ set(SOURCES
|
||||
src/ui/SpectrumDisplay.cpp
|
||||
src/ui/Cursors.cpp
|
||||
src/ui/Measurements.cpp
|
||||
src/ui/ControlPanel.cpp
|
||||
src/ui/DisplayPanel.cpp
|
||||
src/ui/Application.cpp
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
#include "core/Types.h"
|
||||
#include "core/Config.h"
|
||||
#include "audio/AudioEngine.h"
|
||||
#include "ui/UIState.h"
|
||||
#include "ui/ControlPanel.h"
|
||||
#include "ui/DisplayPanel.h"
|
||||
#include "ui/ColorMap.h"
|
||||
#include "ui/WaterfallDisplay.h"
|
||||
#include "ui/SpectrumDisplay.h"
|
||||
@@ -11,7 +14,6 @@
|
||||
|
||||
#include <SDL.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace baudmine {
|
||||
|
||||
@@ -28,16 +30,11 @@ public:
|
||||
private:
|
||||
void processAudio();
|
||||
void render();
|
||||
void renderControlPanel();
|
||||
void renderSpectrumPanel();
|
||||
void renderWaterfallPanel();
|
||||
void handleSpectrumInput(float posX, float posY, float sizeX, float sizeY);
|
||||
|
||||
void openDevice();
|
||||
void openMultiDevice();
|
||||
void openFile(const std::string& path, InputFormat format, double sampleRate);
|
||||
void updateAnalyzerSettings();
|
||||
void renderMathPanel();
|
||||
|
||||
void loadConfig();
|
||||
void saveConfig() const;
|
||||
@@ -47,114 +44,42 @@ private:
|
||||
SDL_GLContext glContext_ = nullptr;
|
||||
bool running_ = false;
|
||||
|
||||
// Audio engine (owns sources, analyzers, math channels)
|
||||
// Core subsystems
|
||||
AudioEngine audio_;
|
||||
UIState ui_;
|
||||
ControlPanel controlPanel_;
|
||||
DisplayPanel displayPanel_;
|
||||
|
||||
// UI state
|
||||
// Shared UI components
|
||||
ColorMap colorMap_;
|
||||
WaterfallDisplay waterfall_;
|
||||
SpectrumDisplay specDisplay_;
|
||||
Cursors cursors_;
|
||||
Measurements measurements_;
|
||||
|
||||
// Display settings
|
||||
float minDB_ = -120.0f;
|
||||
float maxDB_ = 0.0f;
|
||||
FreqScale freqScale_ = FreqScale::Linear;
|
||||
bool paused_ = false;
|
||||
// UI scaling
|
||||
bool vsync_ = true;
|
||||
float uiScale_ = 0.0f; // 0 = auto (use DPI), >0 = manual override
|
||||
float appliedScale_ = 0.0f; // currently applied user-facing scale
|
||||
float pendingScale_ = 0.0f; // deferred scale (applied before next frame)
|
||||
float logicalScale_ = 1.0f; // scale after compensating for framebuffer DPI
|
||||
float lastDpr_ = 0.0f; // last devicePixelRatio (to detect changes)
|
||||
float uiScale_ = 0.0f;
|
||||
float appliedScale_ = 0.0f;
|
||||
float pendingScale_ = 0.0f;
|
||||
float logicalScale_ = 1.0f;
|
||||
float lastDpr_ = 0.0f;
|
||||
void applyUIScale(float scale);
|
||||
void requestUIScale(float scale); // safe to call mid-frame
|
||||
void requestUIScale(float scale);
|
||||
void syncCanvasSize();
|
||||
|
||||
// FFT size options
|
||||
static constexpr int kFFTSizes[] = {256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536};
|
||||
static constexpr int kNumFFTSizes = 9;
|
||||
int fftSizeIdx_ = 4; // default 4096
|
||||
|
||||
// Overlap (continuous 0–95%)
|
||||
float overlapPct_ = 50.0f;
|
||||
|
||||
// Window
|
||||
int windowIdx_ = static_cast<int>(WindowType::BlackmanHarris);
|
||||
|
||||
// Color map
|
||||
int colorMapIdx_ = static_cast<int>(ColorMapType::Magma);
|
||||
|
||||
// File playback
|
||||
std::string filePath_;
|
||||
int fileFormatIdx_ = 0;
|
||||
float fileSampleRate_ = 48000.0f;
|
||||
bool fileLoop_ = true;
|
||||
|
||||
// Channel colors (up to kMaxChannels). Defaults: L=green, R=purple.
|
||||
ImVec4 channelColors_[kMaxChannels] = {
|
||||
{0.20f, 0.90f, 0.30f, 1.0f}, // green
|
||||
{0.70f, 0.30f, 1.00f, 1.0f}, // purple
|
||||
{1.00f, 0.55f, 0.00f, 1.0f}, // orange
|
||||
{0.00f, 0.75f, 1.00f, 1.0f}, // cyan
|
||||
{1.00f, 0.25f, 0.25f, 1.0f}, // red
|
||||
{1.00f, 1.00f, 0.30f, 1.0f}, // yellow
|
||||
{0.50f, 0.80f, 0.50f, 1.0f}, // light green
|
||||
{0.80f, 0.50f, 0.80f, 1.0f}, // pink
|
||||
};
|
||||
int waterfallChannel_ = 0; // which channel drives the waterfall (single mode)
|
||||
bool waterfallMultiCh_ = true; // true = multi-channel overlay mode
|
||||
bool channelEnabled_[kMaxChannels] = {true,true,true,true,true,true,true,true};
|
||||
|
||||
// Frequency zoom/pan (normalized 0–1 over full bandwidth)
|
||||
float viewLo_ = 0.0f; // left edge
|
||||
float viewHi_ = 0.5f; // right edge (default 2x zoom from left)
|
||||
|
||||
// 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;
|
||||
|
||||
// Hover state: which panel is being hovered
|
||||
enum class HoverPanel { None, Spectrum, Waterfall };
|
||||
HoverPanel hoverPanel_ = HoverPanel::None;
|
||||
float hoverWfTimeOffset_ = 0.0f; // seconds from newest line
|
||||
|
||||
// Touch gesture state (pinch-zoom / two-finger pan)
|
||||
struct TouchState {
|
||||
int count = 0; // active finger count
|
||||
float startDist = 0.0f; // initial distance between two fingers
|
||||
float startLo = 0.0f; // viewLo_ at gesture start
|
||||
float startHi = 0.0f; // viewHi_ at gesture start
|
||||
float startCenterX = 0.0f; // midpoint screen-X at gesture start
|
||||
float lastCenterX = 0.0f; // last midpoint screen-X
|
||||
float lastDist = 0.0f; // last distance between fingers
|
||||
} touch_;
|
||||
void handleTouchEvent(const SDL_Event& event);
|
||||
|
||||
// Config persistence
|
||||
Config config_;
|
||||
|
||||
// UI visibility
|
||||
bool showSidebar_ = true;
|
||||
|
||||
#ifndef IMGUI_DISABLE_DEBUG_TOOLS
|
||||
// ImGui debug windows
|
||||
bool showDemoWindow_ = false;
|
||||
bool showMetricsWindow_ = false;
|
||||
bool showDebugLog_ = false;
|
||||
bool showStackTool_ = false;
|
||||
#endif
|
||||
|
||||
// Pre-allocated scratch buffers (avoid per-frame heap allocations)
|
||||
std::vector<std::vector<float>> wfSpectraScratch_;
|
||||
std::vector<WaterfallChannelInfo> wfChInfoScratch_;
|
||||
std::vector<std::vector<float>> allSpectraScratch_;
|
||||
std::vector<ChannelStyle> stylesScratch_;
|
||||
// Config persistence
|
||||
Config config_;
|
||||
};
|
||||
|
||||
} // namespace baudmine
|
||||
|
||||
396
src/ui/ControlPanel.cpp
Normal file
396
src/ui/ControlPanel.cpp
Normal file
@@ -0,0 +1,396 @@
|
||||
#include "ui/ControlPanel.h"
|
||||
#include "audio/AudioEngine.h"
|
||||
#include "ui/SpectrumDisplay.h"
|
||||
#include "ui/Cursors.h"
|
||||
#include "ui/Measurements.h"
|
||||
#include "ui/ColorMap.h"
|
||||
#include "ui/WaterfallDisplay.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
namespace baudmine {
|
||||
|
||||
void ControlPanel::render(AudioEngine& audio, UIState& ui,
|
||||
SpectrumDisplay& specDisplay, Cursors& cursors,
|
||||
Measurements& measurements, ColorMap& colorMap,
|
||||
WaterfallDisplay& waterfall) {
|
||||
const auto& settings = audio.settings();
|
||||
|
||||
// ── Playback ──
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3.0f;
|
||||
if (ImGui::Button(ui.paused ? "Resume" : "Pause", {btnW, 0}))
|
||||
ui.paused = !ui.paused;
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Clear", {btnW, 0})) {
|
||||
audio.clearHistory();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Peak", {btnW, 0})) {
|
||||
int pkCh = std::clamp(ui.waterfallChannel, 0, audio.totalNumSpectra() - 1);
|
||||
cursors.snapToPeak(audio.getSpectrum(pkCh),
|
||||
settings.sampleRate, settings.isIQ,
|
||||
settings.fftSize);
|
||||
}
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Snap cursor A to peak");
|
||||
|
||||
// ── FFT ──
|
||||
ImGui::Spacing();
|
||||
if (ImGui::CollapsingHeader("FFT", ImGuiTreeNodeFlags_DefaultOpen)) {
|
||||
const char* sizeNames[] = {"256", "512", "1024", "2048", "4096",
|
||||
"8192", "16384", "32768", "65536"};
|
||||
float availSpace = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x);
|
||||
ImGui::SetNextItemWidth(availSpace * 0.35f);
|
||||
if (ImGui::Combo("##fftsize", &fftSizeIdx, sizeNames, kNumFFTSizes)) {
|
||||
audio.settings().fftSize = kFFTSizes[fftSizeIdx];
|
||||
flagUpdate();
|
||||
flagSave();
|
||||
}
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("FFT Size");
|
||||
|
||||
ImGui::SameLine();
|
||||
const char* winNames[] = {"Rectangular", "Hann", "Hamming", "Blackman",
|
||||
"Blackman-Harris", "Kaiser", "Flat Top"};
|
||||
ImGui::SetNextItemWidth(availSpace * 0.65f);
|
||||
if (ImGui::Combo("##window", &windowIdx, winNames,
|
||||
static_cast<int>(WindowType::Count))) {
|
||||
audio.settings().window = static_cast<WindowType>(windowIdx);
|
||||
flagUpdate();
|
||||
flagSave();
|
||||
}
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Window Function");
|
||||
|
||||
if (settings.window == WindowType::Kaiser) {
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
if (ImGui::SliderFloat("##kaiser", &audio.settings().kaiserBeta, 0.0f, 20.0f, "Kaiser: %.1f"))
|
||||
flagUpdate();
|
||||
}
|
||||
|
||||
// Overlap
|
||||
{
|
||||
int hopSamples = static_cast<int>(settings.fftSize * (1.0f - settings.overlap));
|
||||
if (hopSamples < 1) hopSamples = 1;
|
||||
int overlapSamples = settings.fftSize - hopSamples;
|
||||
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
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);
|
||||
audio.settings().overlap = overlapPct / 100.0f;
|
||||
flagUpdate();
|
||||
flagSave();
|
||||
}
|
||||
|
||||
char overlayText[64];
|
||||
std::snprintf(overlayText, sizeof(overlayText), "%.1f%% (%d samples)", overlapPct, overlapSamples);
|
||||
ImVec2 textSize = ImGui::CalcTextSize(overlayText);
|
||||
ImVec2 rMin = ImGui::GetItemRectMin();
|
||||
ImVec2 rMax = ImGui::GetItemRectMax();
|
||||
float tx = rMin.x + ((rMax.x - rMin.x) - textSize.x) * 0.5f;
|
||||
float ty = rMin.y + ((rMax.y - rMin.y) - textSize.y) * 0.5f;
|
||||
ImGui::GetWindowDrawList()->AddText({tx, ty}, IM_COL32(255, 255, 255, 220), overlayText);
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Overlap");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Display ──
|
||||
ImGui::Spacing();
|
||||
if (ImGui::CollapsingHeader("Display", ImGuiTreeNodeFlags_DefaultOpen)) {
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
ImGui::DragFloatRange2("##dbrange", &ui.minDB, &ui.maxDB, 1.0f, -200.0f, 20.0f,
|
||||
"Min: %.0f dB", "Max: %.0f dB");
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("dB Range (min / max)");
|
||||
|
||||
ImGui::Checkbox("Peak Hold", &specDisplay.peakHoldEnable);
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Draws a \"maximum\" line in the spectrogram");
|
||||
if (specDisplay.peakHoldEnable) {
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x
|
||||
- ImGui::CalcTextSize("Clear").x
|
||||
- ImGui::GetStyle().ItemSpacing.x
|
||||
- ImGui::GetStyle().FramePadding.x * 2);
|
||||
ImGui::SliderFloat("##decay", &specDisplay.peakHoldDecay, 0.0f, 120.0f, "%.0f dB/s");
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Decay rate");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Clear##peakhold"))
|
||||
specDisplay.clearPeakHold();
|
||||
}
|
||||
|
||||
{
|
||||
bool isLog = (ui.freqScale == FreqScale::Logarithmic);
|
||||
bool canLog = !settings.isIQ;
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::Text("Freq. scale:");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(isLog ? "Logarithmic" : "Linear", {ImGui::GetContentRegionAvail().x, 0})) {
|
||||
if (canLog) {
|
||||
constexpr float kMinBF = 0.001f;
|
||||
float logMin = std::log10(kMinBF);
|
||||
auto screenToBin = [&](float sf) -> float {
|
||||
if (isLog) return std::pow(10.0f, logMin + sf * (0.0f - logMin));
|
||||
return sf;
|
||||
};
|
||||
auto binToScreen = [&](float bf, bool toLog) -> float {
|
||||
if (toLog) {
|
||||
if (bf < kMinBF) bf = kMinBF;
|
||||
return (std::log10(bf) - logMin) / (0.0f - logMin);
|
||||
}
|
||||
return bf;
|
||||
};
|
||||
float bfLo = screenToBin(ui.viewLo);
|
||||
float bfHi = screenToBin(ui.viewHi);
|
||||
bool newLog = !isLog;
|
||||
ui.freqScale = newLog ? FreqScale::Logarithmic : FreqScale::Linear;
|
||||
ui.viewLo = std::clamp(binToScreen(bfLo, newLog), 0.0f, 1.0f);
|
||||
ui.viewHi = std::clamp(binToScreen(bfHi, newLog), 0.0f, 1.0f);
|
||||
if (ui.viewHi <= ui.viewLo) { ui.viewLo = 0.0f; ui.viewHi = 1.0f; }
|
||||
flagSave();
|
||||
}
|
||||
}
|
||||
if (!canLog && ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Log scale not available in I/Q mode");
|
||||
}
|
||||
|
||||
{
|
||||
float span = ui.viewHi - ui.viewLo;
|
||||
float zoomX = 1.0f / span;
|
||||
float resetBtnW = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2;
|
||||
float zoomLabelW = ImGui::CalcTextSize("Zoom:").x + ImGui::GetStyle().ItemSpacing.x;
|
||||
float sliderW = ImGui::GetContentRegionAvail().x - zoomLabelW - resetBtnW - ImGui::GetStyle().ItemSpacing.x;
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::Text("Zoom:");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(sliderW);
|
||||
if (ImGui::SliderFloat("##zoom", &zoomX, 1.0f, 200.0f, "%.1fx", ImGuiSliderFlags_Logarithmic)) {
|
||||
zoomX = std::clamp(zoomX, 1.0f, 1000.0f);
|
||||
float newSpan = 1.0f / zoomX;
|
||||
ui.viewLo = 0.0f;
|
||||
ui.viewHi = std::clamp(newSpan, 0.0f, 1.0f);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Reset##zoom")) {
|
||||
ui.viewLo = 0.0f;
|
||||
ui.viewHi = 0.5f;
|
||||
}
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Reset to 2x zoom");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channels ──
|
||||
ImGui::Spacing();
|
||||
{
|
||||
int nCh = audio.totalNumSpectra();
|
||||
bool isMulti = ui.waterfallMultiCh && nCh > 1;
|
||||
|
||||
float widgetW = (nCh > 1) ? ImGui::CalcTextSize(" Multi ").x + ImGui::GetStyle().FramePadding.x * 2 : 0.0f;
|
||||
float gap = ImGui::GetStyle().ItemSpacing.x * 0.25f;
|
||||
ImVec2 hdrMin = ImGui::GetCursorScreenPos();
|
||||
float winLeft = ImGui::GetWindowPos().x;
|
||||
float hdrRight = hdrMin.x + ImGui::GetContentRegionAvail().x;
|
||||
ImGui::PushClipRect({winLeft, hdrMin.y}, {hdrRight - widgetW - gap, hdrMin.y + 200}, true);
|
||||
bool headerOpen = ImGui::CollapsingHeader("##channels_hdr",
|
||||
ImGuiTreeNodeFlags_DefaultOpen |
|
||||
ImGuiTreeNodeFlags_AllowOverlap);
|
||||
ImGui::PopClipRect();
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("Channels");
|
||||
if (nCh > 1) {
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - widgetW + ImGui::GetStyle().FramePadding.x);
|
||||
if (ImGui::Button(isMulti ? " Multi " : "Single ", {widgetW, 0})) {
|
||||
ui.waterfallMultiCh = !ui.waterfallMultiCh;
|
||||
}
|
||||
}
|
||||
|
||||
if (headerOpen) {
|
||||
if (isMulti) {
|
||||
static const char* defaultNames[] = {
|
||||
"Left", "Right", "Ch 3", "Ch 4", "Ch 5", "Ch 6", "Ch 7", "Ch 8"
|
||||
};
|
||||
for (int ch = 0; ch < nCh && ch < kMaxChannels; ++ch) {
|
||||
ImGui::PushID(ch);
|
||||
ImGui::Checkbox("##en", &ui.channelEnabled[ch]);
|
||||
ImGui::SameLine();
|
||||
ImGui::ColorEdit3(defaultNames[ch], &ui.channelColors[ch].x,
|
||||
ImGuiColorEditFlags_NoInputs);
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("%s", audio.getDeviceName(ch));
|
||||
ImGui::PopID();
|
||||
}
|
||||
} else {
|
||||
const char* cmNames[] = {"Magma", "Viridis", "Inferno", "Plasma", "Grayscale"};
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
if (ImGui::Combo("##colormap", &colorMapIdx, cmNames,
|
||||
static_cast<int>(ColorMapType::Count))) {
|
||||
colorMap.setType(static_cast<ColorMapType>(colorMapIdx));
|
||||
waterfall.setColorMap(colorMap);
|
||||
flagSave();
|
||||
}
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Color Map");
|
||||
|
||||
if (nCh > 1) {
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
if (ImGui::SliderInt("##wfch", &ui.waterfallChannel, 0, nCh - 1))
|
||||
ui.waterfallChannel = std::clamp(ui.waterfallChannel, 0, nCh - 1);
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Waterfall Channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Math ──
|
||||
ImGui::Spacing();
|
||||
{
|
||||
float btnW2 = ImGui::GetFrameHeight();
|
||||
float gap = ImGui::GetStyle().ItemSpacing.x * 0.25f;
|
||||
ImVec2 hdrMin = ImGui::GetCursorScreenPos();
|
||||
float winLeft = ImGui::GetWindowPos().x;
|
||||
float hdrRight = hdrMin.x + ImGui::GetContentRegionAvail().x;
|
||||
ImGui::PushClipRect({winLeft, hdrMin.y}, {hdrRight - btnW2 - gap, hdrMin.y + 200}, true);
|
||||
bool mathOpen = ImGui::CollapsingHeader("##math_hdr",
|
||||
ImGuiTreeNodeFlags_DefaultOpen |
|
||||
ImGuiTreeNodeFlags_AllowOverlap);
|
||||
ImGui::PopClipRect();
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("Math");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - btnW2 + ImGui::GetStyle().FramePadding.x);
|
||||
if (ImGui::Button("+##addmath", {btnW2, 0})) {
|
||||
int nPhys = audio.totalNumSpectra();
|
||||
MathChannel mc;
|
||||
mc.op = MathOp::Subtract;
|
||||
mc.sourceX = 0;
|
||||
mc.sourceY = std::min(1, nPhys - 1);
|
||||
mc.color[0] = 1.0f; mc.color[1] = 1.0f; mc.color[2] = 0.5f; mc.color[3] = 1.0f;
|
||||
audio.mathChannels().push_back(mc);
|
||||
}
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Add math channel");
|
||||
|
||||
if (mathOpen) {
|
||||
renderMathPanel(audio);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cursors ──
|
||||
ImGui::Spacing();
|
||||
{
|
||||
float btnW2 = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2;
|
||||
float gap = ImGui::GetStyle().ItemSpacing.x * 0.25f;
|
||||
ImVec2 hdrMin = ImGui::GetCursorScreenPos();
|
||||
float winLeft = ImGui::GetWindowPos().x;
|
||||
float hdrRight = hdrMin.x + ImGui::GetContentRegionAvail().x;
|
||||
ImGui::PushClipRect({winLeft, hdrMin.y}, {hdrRight - btnW2 - gap, hdrMin.y + 200}, true);
|
||||
bool cursorsOpen = ImGui::CollapsingHeader("##cursors_hdr",
|
||||
ImGuiTreeNodeFlags_DefaultOpen |
|
||||
ImGuiTreeNodeFlags_AllowOverlap);
|
||||
ImGui::PopClipRect();
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("Cursors");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - btnW2 + ImGui::GetStyle().FramePadding.x);
|
||||
if (ImGui::SmallButton("Reset##cursors")) {
|
||||
cursors.cursorA.active = false;
|
||||
cursors.cursorB.active = false;
|
||||
}
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Clear cursors A and B");
|
||||
|
||||
if (cursorsOpen) {
|
||||
bool prevSnap = cursors.snapToPeaks;
|
||||
cursors.drawPanel();
|
||||
if (cursors.snapToPeaks != prevSnap) flagSave();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Measurements ──
|
||||
ImGui::Spacing();
|
||||
{
|
||||
float cbW = ImGui::GetFrameHeight();
|
||||
float gap = ImGui::GetStyle().ItemSpacing.x * 0.25f;
|
||||
ImVec2 hdrMin = ImGui::GetCursorScreenPos();
|
||||
float winLeft = ImGui::GetWindowPos().x;
|
||||
float hdrRight = hdrMin.x + ImGui::GetContentRegionAvail().x;
|
||||
ImGui::PushClipRect({winLeft, hdrMin.y}, {hdrRight - cbW - gap, hdrMin.y + 200}, true);
|
||||
bool headerOpen = ImGui::CollapsingHeader("##meas_hdr",
|
||||
ImGuiTreeNodeFlags_DefaultOpen |
|
||||
ImGuiTreeNodeFlags_AllowOverlap);
|
||||
ImGui::PopClipRect();
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("Measurements");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - cbW + ImGui::GetStyle().FramePadding.x);
|
||||
ImGui::Checkbox("##meas_en", &measurements.enabled);
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Enable measurements");
|
||||
|
||||
if (headerOpen) {
|
||||
float prevMin = measurements.traceMinFreq;
|
||||
float prevMax = measurements.traceMaxFreq;
|
||||
measurements.drawPanel();
|
||||
if (measurements.traceMinFreq != prevMin || measurements.traceMaxFreq != prevMax)
|
||||
flagSave();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status (bottom) ──
|
||||
ImGui::Separator();
|
||||
ImGui::TextDisabled("Mode: %s", settings.isIQ ? "I/Q"
|
||||
: (settings.numChannels > 1 ? "Multi-ch" : "Real"));
|
||||
}
|
||||
|
||||
void ControlPanel::renderMathPanel(AudioEngine& audio) {
|
||||
int nPhys = audio.totalNumSpectra();
|
||||
auto& mathChannels = audio.mathChannels();
|
||||
|
||||
static const char* chNames[] = {
|
||||
"Ch 0 (L)", "Ch 1 (R)", "Ch 2", "Ch 3", "Ch 4", "Ch 5", "Ch 6", "Ch 7"
|
||||
};
|
||||
|
||||
int toRemove = -1;
|
||||
for (int mi = 0; mi < static_cast<int>(mathChannels.size()); ++mi) {
|
||||
auto& mc = mathChannels[mi];
|
||||
ImGui::PushID(1000 + mi);
|
||||
|
||||
ImGui::Checkbox("##en", &mc.enabled);
|
||||
ImGui::SameLine();
|
||||
ImGui::ColorEdit3("##col", mc.color, ImGuiColorEditFlags_NoInputs);
|
||||
ImGui::SameLine();
|
||||
|
||||
if (ImGui::BeginCombo("##op", mathOpName(mc.op), ImGuiComboFlags_NoPreview)) {
|
||||
for (int o = 0; o < static_cast<int>(MathOp::Count); ++o) {
|
||||
auto op = static_cast<MathOp>(o);
|
||||
if (ImGui::Selectable(mathOpName(op), mc.op == op))
|
||||
mc.op = op;
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("%s", mathOpName(mc.op));
|
||||
|
||||
ImGui::SetNextItemWidth(80);
|
||||
ImGui::Combo("X", &mc.sourceX, chNames, std::min(nPhys, kMaxChannels));
|
||||
|
||||
if (mathOpIsBinary(mc.op)) {
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(80);
|
||||
ImGui::Combo("Y", &mc.sourceY, chNames, std::min(nPhys, kMaxChannels));
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox("WF", &mc.waterfall);
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Show on waterfall");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("X##del"))
|
||||
toRemove = mi;
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
if (toRemove >= 0)
|
||||
mathChannels.erase(mathChannels.begin() + toRemove);
|
||||
}
|
||||
|
||||
} // namespace baudmine
|
||||
53
src/ui/ControlPanel.h
Normal file
53
src/ui/ControlPanel.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/Types.h"
|
||||
#include "ui/UIState.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace baudmine {
|
||||
|
||||
class AudioEngine;
|
||||
class SpectrumDisplay;
|
||||
class Cursors;
|
||||
class Measurements;
|
||||
class ColorMap;
|
||||
class WaterfallDisplay;
|
||||
|
||||
class ControlPanel {
|
||||
public:
|
||||
// Render the sidebar. Returns true if config should be saved.
|
||||
void render(AudioEngine& audio, UIState& ui,
|
||||
SpectrumDisplay& specDisplay, Cursors& cursors,
|
||||
Measurements& measurements, ColorMap& colorMap,
|
||||
WaterfallDisplay& waterfall);
|
||||
|
||||
// Action flags — checked and cleared by Application after render().
|
||||
bool needsSave() { bool v = needsSave_; needsSave_ = false; return v; }
|
||||
bool needsAnalyzerUpdate() { bool v = needsUpdate_; needsUpdate_ = false; return v; }
|
||||
|
||||
// FFT / analysis controls
|
||||
static constexpr int kFFTSizes[] = {256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536};
|
||||
static constexpr int kNumFFTSizes = 9;
|
||||
int fftSizeIdx = 4;
|
||||
float overlapPct = 50.0f;
|
||||
int windowIdx = static_cast<int>(WindowType::BlackmanHarris);
|
||||
int colorMapIdx = static_cast<int>(ColorMapType::Magma);
|
||||
|
||||
// File playback
|
||||
std::string filePath;
|
||||
int fileFormatIdx = 0;
|
||||
float fileSampleRate = 48000.0f;
|
||||
bool fileLoop = true;
|
||||
|
||||
private:
|
||||
void renderMathPanel(AudioEngine& audio);
|
||||
|
||||
bool needsSave_ = false;
|
||||
bool needsUpdate_ = false;
|
||||
|
||||
void flagSave() { needsSave_ = true; }
|
||||
void flagUpdate() { needsUpdate_ = true; }
|
||||
};
|
||||
|
||||
} // namespace baudmine
|
||||
478
src/ui/DisplayPanel.cpp
Normal file
478
src/ui/DisplayPanel.cpp
Normal file
@@ -0,0 +1,478 @@
|
||||
#include "ui/DisplayPanel.h"
|
||||
#include "audio/AudioEngine.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
namespace baudmine {
|
||||
|
||||
bool DisplayPanel::splitReleased() {
|
||||
bool v = splitWasReleased_;
|
||||
splitWasReleased_ = false;
|
||||
return v;
|
||||
}
|
||||
|
||||
void DisplayPanel::renderSpectrum(AudioEngine& audio, UIState& ui,
|
||||
SpectrumDisplay& specDisplay, Cursors& cursors,
|
||||
Measurements& measurements) {
|
||||
const auto& settings = audio.settings();
|
||||
|
||||
float availW = ImGui::GetContentRegionAvail().x;
|
||||
float specH = ImGui::GetContentRegionAvail().y;
|
||||
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
specPosX = pos.x;
|
||||
specPosY = pos.y;
|
||||
specSizeX = availW;
|
||||
specSizeY = specH;
|
||||
|
||||
int nPhys = audio.totalNumSpectra();
|
||||
const auto& mathChannels = audio.mathChannels();
|
||||
const auto& mathSpectra = audio.mathSpectra();
|
||||
int nMath = static_cast<int>(mathSpectra.size());
|
||||
|
||||
allSpectraScratch_.clear();
|
||||
stylesScratch_.clear();
|
||||
|
||||
for (int ch = 0; ch < nPhys; ++ch) {
|
||||
if (!ui.channelEnabled[ch % kMaxChannels]) continue;
|
||||
allSpectraScratch_.push_back(audio.getSpectrum(ch));
|
||||
const auto& c = ui.channelColors[ch % kMaxChannels];
|
||||
uint8_t r = static_cast<uint8_t>(c.x * 255);
|
||||
uint8_t g = static_cast<uint8_t>(c.y * 255);
|
||||
uint8_t b = static_cast<uint8_t>(c.z * 255);
|
||||
stylesScratch_.push_back({IM_COL32(r, g, b, 220), IM_COL32(r, g, b, 35)});
|
||||
}
|
||||
|
||||
for (int mi = 0; mi < nMath; ++mi) {
|
||||
if (mi < static_cast<int>(mathChannels.size()) && mathChannels[mi].enabled) {
|
||||
allSpectraScratch_.push_back(mathSpectra[mi]);
|
||||
const auto& c = mathChannels[mi].color;
|
||||
uint8_t r = static_cast<uint8_t>(c[0] * 255);
|
||||
uint8_t g = static_cast<uint8_t>(c[1] * 255);
|
||||
uint8_t b = static_cast<uint8_t>(c[2] * 255);
|
||||
stylesScratch_.push_back({IM_COL32(r, g, b, 220), IM_COL32(r, g, b, 35)});
|
||||
}
|
||||
}
|
||||
|
||||
specDisplay.updatePeakHold(allSpectraScratch_);
|
||||
specDisplay.draw(allSpectraScratch_, stylesScratch_, ui.minDB, ui.maxDB,
|
||||
settings.sampleRate, settings.isIQ, ui.freqScale,
|
||||
specPosX, specPosY, specSizeX, specSizeY,
|
||||
ui.viewLo, ui.viewHi);
|
||||
|
||||
cursors.draw(specDisplay, specPosX, specPosY, specSizeX, specSizeY,
|
||||
settings.sampleRate, settings.isIQ, ui.freqScale, ui.minDB, ui.maxDB,
|
||||
ui.viewLo, ui.viewHi);
|
||||
|
||||
measurements.draw(specDisplay, specPosX, specPosY, specSizeX, specSizeY,
|
||||
settings.sampleRate, settings.isIQ, ui.freqScale, ui.minDB, ui.maxDB,
|
||||
ui.viewLo, ui.viewHi);
|
||||
|
||||
handleSpectrumInput(audio, ui, specDisplay, cursors,
|
||||
specPosX, specPosY, specSizeX, specSizeY);
|
||||
|
||||
ImGui::Dummy({availW, specH});
|
||||
}
|
||||
|
||||
void DisplayPanel::renderWaterfall(AudioEngine& audio, UIState& ui,
|
||||
WaterfallDisplay& waterfall, SpectrumDisplay& specDisplay,
|
||||
Cursors& cursors, Measurements& measurements,
|
||||
ColorMap& colorMap) {
|
||||
const auto& settings = audio.settings();
|
||||
|
||||
float availW = ImGui::GetContentRegionAvail().x;
|
||||
constexpr float kSplitterH = 6.0f;
|
||||
float parentH = ImGui::GetContentRegionAvail().y;
|
||||
float availH = (parentH - kSplitterH) * (1.0f - spectrumFrac);
|
||||
|
||||
int neededH = std::max(1024, static_cast<int>(availH) + 1);
|
||||
int binCount = std::max(1, audio.spectrumSize());
|
||||
if (binCount != waterfall.width() || waterfall.height() < neededH) {
|
||||
waterfall.resize(binCount, neededH);
|
||||
waterfall.setColorMap(colorMap);
|
||||
}
|
||||
|
||||
if (waterfall.textureID()) {
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
auto texID = static_cast<ImTextureID>(waterfall.textureID());
|
||||
|
||||
int h = waterfall.height();
|
||||
int screenRows = std::min(static_cast<int>(availH), h);
|
||||
int newestRow = (waterfall.currentRow() + 1) % h;
|
||||
|
||||
float rowToV = 1.0f / h;
|
||||
bool logMode = (ui.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},
|
||||
{ui.viewLo, v1}, {ui.viewHi, v0});
|
||||
} 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 = ui.viewLo + sL * (ui.viewHi - ui.viewLo);
|
||||
float vfR = ui.viewLo + sR * (ui.viewHi - ui.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, v1}, {uR, v0});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
float pxPerRow = availH / static_cast<float>(screenRows);
|
||||
|
||||
if (newestRow + screenRows <= h) {
|
||||
drawSpan(newestRow, screenRows, pos.y, availH);
|
||||
} else {
|
||||
int firstCount = h - newestRow;
|
||||
int secondCount = screenRows - firstCount;
|
||||
|
||||
float secondH = secondCount * pxPerRow;
|
||||
if (secondCount > 0)
|
||||
drawSpan(0, secondCount, pos.y, secondH);
|
||||
|
||||
float firstH = availH - secondH;
|
||||
drawSpan(newestRow, firstCount, pos.y + secondH, firstH);
|
||||
}
|
||||
|
||||
// ── Frequency axis labels ──
|
||||
ImU32 textCol = IM_COL32(180, 180, 200, 200);
|
||||
double freqFullMin = settings.isIQ ? -settings.sampleRate / 2.0 : 0.0;
|
||||
double freqFullMax = settings.isIQ ? settings.sampleRate / 2.0 : settings.sampleRate / 2.0;
|
||||
|
||||
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;
|
||||
float vf = ui.viewLo + frac * (ui.viewHi - ui.viewLo);
|
||||
double freq = viewFracToFreq(vf);
|
||||
float x = pos.x + frac * availW;
|
||||
|
||||
char label[32];
|
||||
if (std::abs(freq) >= 1e6)
|
||||
std::snprintf(label, sizeof(label), "%.2fM", freq / 1e6);
|
||||
else if (std::abs(freq) >= 1e3)
|
||||
std::snprintf(label, sizeof(label), "%.1fk", freq / 1e3);
|
||||
else
|
||||
std::snprintf(label, sizeof(label), "%.0f", freq);
|
||||
|
||||
dl->AddText({x + 2, pos.y + 2}, textCol, label);
|
||||
}
|
||||
|
||||
wfPosX = pos.x; wfPosY = pos.y; wfSizeX = availW; wfSizeY = availH;
|
||||
|
||||
measurements.drawWaterfall(specDisplay, wfPosX, wfPosY, wfSizeX, wfSizeY,
|
||||
settings.sampleRate, settings.isIQ, ui.freqScale,
|
||||
ui.viewLo, ui.viewHi, screenRows, audio.spectrumSize());
|
||||
|
||||
// ── 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;
|
||||
|
||||
if (inWaterfall) {
|
||||
hoverPanel = HoverPanel::Waterfall;
|
||||
double freq = specDisplay.screenXToFreq(mx, pos.x, availW,
|
||||
settings.sampleRate,
|
||||
settings.isIQ, ui.freqScale,
|
||||
ui.viewLo, ui.viewHi);
|
||||
int bins = audio.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);
|
||||
|
||||
float yFrac = 1.0f - (my - pos.y) / availH;
|
||||
int hopSamples = static_cast<int>(settings.fftSize * (1.0f - settings.overlap));
|
||||
if (hopSamples < 1) hopSamples = 1;
|
||||
double secondsPerLine = static_cast<double>(hopSamples) / settings.sampleRate;
|
||||
hoverWfTimeOff = static_cast<float>(yFrac * screenRows * secondsPerLine);
|
||||
|
||||
int curCh = std::clamp(ui.waterfallChannel, 0, audio.totalNumSpectra() - 1);
|
||||
const auto& spec = audio.getSpectrum(curCh);
|
||||
if (!spec.empty()) {
|
||||
cursors.hover = {true, freq, spec[bin], bin};
|
||||
}
|
||||
}
|
||||
|
||||
if (inWaterfall) {
|
||||
if (io.MouseWheel != 0) {
|
||||
float cursorFrac = (mx - pos.x) / availW;
|
||||
float viewFrac = ui.viewLo + cursorFrac * (ui.viewHi - ui.viewLo);
|
||||
|
||||
float zoomFactor = (io.MouseWheel > 0) ? 0.85f : 1.0f / 0.85f;
|
||||
float newSpan = (ui.viewHi - ui.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; }
|
||||
// Safe to cast away const on ui here — caller passes mutable UIState
|
||||
ui.viewLo = std::clamp(newLo, 0.0f, 1.0f);
|
||||
ui.viewHi = std::clamp(newHi, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 1.0f)) {
|
||||
float dx = io.MouseDelta.x;
|
||||
float panFrac = -dx / availW * (ui.viewHi - ui.viewLo);
|
||||
float newLo = ui.viewLo + panFrac;
|
||||
float newHi = ui.viewHi + panFrac;
|
||||
float span = ui.viewHi - ui.viewLo;
|
||||
if (newLo < 0.0f) { newLo = 0.0f; newHi = span; }
|
||||
if (newHi > 1.0f) { newHi = 1.0f; newLo = 1.0f - span; }
|
||||
ui.viewLo = newLo;
|
||||
ui.viewHi = newHi;
|
||||
}
|
||||
|
||||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Middle)) {
|
||||
ui.viewLo = 0.0f;
|
||||
ui.viewHi = 1.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Dummy({availW, availH});
|
||||
}
|
||||
|
||||
void DisplayPanel::renderHoverOverlay(const AudioEngine& audio, const UIState& ui,
|
||||
const Cursors& cursors, const SpectrumDisplay& specDisplay) {
|
||||
if (!cursors.hover.active || specSizeX <= 0 || wfSizeX <= 0)
|
||||
return;
|
||||
|
||||
const auto& settings = audio.settings();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
|
||||
float hx = specDisplay.freqToScreenX(cursors.hover.freq,
|
||||
specPosX, specSizeX, settings.sampleRate,
|
||||
settings.isIQ, ui.freqScale, ui.viewLo, ui.viewHi);
|
||||
ImU32 hoverCol = IM_COL32(200, 200, 200, 80);
|
||||
|
||||
dl->AddLine({hx, specPosY}, {hx, specPosY + specSizeY}, hoverCol, 1.0f);
|
||||
|
||||
char freqLabel[48];
|
||||
fmtFreq(freqLabel, sizeof(freqLabel), cursors.hover.freq);
|
||||
|
||||
ImVec2 tSz = ImGui::CalcTextSize(freqLabel);
|
||||
float lx = std::min(hx + 4, wfPosX + wfSizeX - tSz.x - 4);
|
||||
float ly = wfPosY + 2;
|
||||
dl->AddRectFilled({lx - 2, ly - 1}, {lx + tSz.x + 2, ly + tSz.y + 1},
|
||||
IM_COL32(0, 0, 0, 180));
|
||||
dl->AddText({lx, ly}, IM_COL32(220, 220, 240, 240), freqLabel);
|
||||
|
||||
// Hover info (right side)
|
||||
{
|
||||
int bins = audio.spectrumSize();
|
||||
double fMin = settings.isIQ ? -settings.sampleRate / 2.0 : 0.0;
|
||||
double fMax = settings.isIQ ? settings.sampleRate / 2.0 : settings.sampleRate / 2.0;
|
||||
double binCenterFreq = fMin + (static_cast<double>(cursors.hover.bin) + 0.5)
|
||||
/ bins * (fMax - fMin);
|
||||
|
||||
char hoverBuf[128];
|
||||
if (hoverPanel == HoverPanel::Spectrum) {
|
||||
fmtFreqDB(hoverBuf, sizeof(hoverBuf), "", binCenterFreq, cursors.hover.dB);
|
||||
} else if (hoverPanel == HoverPanel::Waterfall) {
|
||||
fmtFreqTime(hoverBuf, sizeof(hoverBuf), "", binCenterFreq, -hoverWfTimeOff);
|
||||
} else {
|
||||
fmtFreq(hoverBuf, sizeof(hoverBuf), binCenterFreq);
|
||||
}
|
||||
|
||||
ImU32 hoverTextCol = IM_COL32(100, 230, 130, 240);
|
||||
float rightEdge = specPosX + specSizeX - 8;
|
||||
float hy2 = specPosY + 4;
|
||||
ImVec2 hSz = ImGui::CalcTextSize(hoverBuf);
|
||||
dl->AddText({rightEdge - hSz.x, hy2}, hoverTextCol, hoverBuf);
|
||||
}
|
||||
}
|
||||
|
||||
void DisplayPanel::handleSpectrumInput(AudioEngine& audio, UIState& ui,
|
||||
SpectrumDisplay& specDisplay, Cursors& cursors,
|
||||
float posX, float posY, float sizeX, float sizeY) {
|
||||
const auto& settings = audio.settings();
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
float mx = io.MousePos.x;
|
||||
float my = io.MousePos.y;
|
||||
|
||||
bool inRegion = mx >= posX && mx <= posX + sizeX &&
|
||||
my >= posY && my <= posY + sizeY;
|
||||
|
||||
if (inRegion) {
|
||||
hoverPanel = HoverPanel::Spectrum;
|
||||
double freq = specDisplay.screenXToFreq(mx, posX, sizeX,
|
||||
settings.sampleRate,
|
||||
settings.isIQ, ui.freqScale,
|
||||
ui.viewLo, ui.viewHi);
|
||||
float dB = specDisplay.screenYToDB(my, posY, sizeY, ui.minDB, ui.maxDB);
|
||||
|
||||
int bins = audio.spectrumSize();
|
||||
double freqMin = settings.isIQ ? -settings.sampleRate / 2.0 : 0.0;
|
||||
double freqMax = settings.isIQ ? settings.sampleRate / 2.0 : settings.sampleRate / 2.0;
|
||||
int bin = static_cast<int>((freq - freqMin) / (freqMax - freqMin) * (bins - 1));
|
||||
bin = std::clamp(bin, 0, bins - 1);
|
||||
|
||||
int curCh = std::clamp(ui.waterfallChannel, 0, audio.totalNumSpectra() - 1);
|
||||
const auto& spec = audio.getSpectrum(curCh);
|
||||
if (!spec.empty()) {
|
||||
dB = spec[bin];
|
||||
cursors.hover = {true, freq, dB, bin};
|
||||
}
|
||||
|
||||
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||
int cBin = cursors.snapToPeaks ? cursors.findLocalPeak(spec, bin, 10) : bin;
|
||||
double cFreq = audio.binToFreq(cBin);
|
||||
cursors.setCursorA(cFreq, spec[cBin], cBin);
|
||||
}
|
||||
if (ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
|
||||
int cBin = cursors.snapToPeaks ? cursors.findLocalPeak(spec, bin, 10) : bin;
|
||||
double cFreq = audio.binToFreq(cBin);
|
||||
cursors.setCursorB(cFreq, spec[cBin], cBin);
|
||||
}
|
||||
|
||||
{
|
||||
if (io.MouseWheel != 0 && (io.KeyCtrl || io.KeyShift)) {
|
||||
float zoom = io.MouseWheel * 5.0f;
|
||||
ui.minDB += zoom;
|
||||
ui.maxDB -= zoom;
|
||||
if (ui.maxDB - ui.minDB < 10.0f) {
|
||||
float mid = (ui.minDB + ui.maxDB) / 2.0f;
|
||||
ui.minDB = mid - 5.0f;
|
||||
ui.maxDB = mid + 5.0f;
|
||||
}
|
||||
}
|
||||
else if (io.MouseWheel != 0) {
|
||||
float cursorFrac = (mx - posX) / sizeX;
|
||||
float viewFrac = ui.viewLo + cursorFrac * (ui.viewHi - ui.viewLo);
|
||||
|
||||
float zoomFactor = (io.MouseWheel > 0) ? 0.85f : 1.0f / 0.85f;
|
||||
float newSpan = (ui.viewHi - ui.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; }
|
||||
ui.viewLo = std::clamp(newLo, 0.0f, 1.0f);
|
||||
ui.viewHi = std::clamp(newHi, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 1.0f)) {
|
||||
float dx = io.MouseDelta.x;
|
||||
float panFrac = -dx / sizeX * (ui.viewHi - ui.viewLo);
|
||||
float newLo = ui.viewLo + panFrac;
|
||||
float newHi = ui.viewHi + panFrac;
|
||||
float span = ui.viewHi - ui.viewLo;
|
||||
if (newLo < 0.0f) { newLo = 0.0f; newHi = span; }
|
||||
if (newHi > 1.0f) { newHi = 1.0f; newLo = 1.0f - span; }
|
||||
ui.viewLo = newLo;
|
||||
ui.viewHi = newHi;
|
||||
}
|
||||
|
||||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Middle)) {
|
||||
ui.viewLo = 0.0f;
|
||||
ui.viewHi = 1.0f;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (hoverPanel != HoverPanel::Waterfall) {
|
||||
hoverPanel = HoverPanel::None;
|
||||
cursors.hover.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DisplayPanel::handleTouch(const SDL_Event& event, UIState& ui, SDL_Window* window) {
|
||||
if (event.type == SDL_FINGERDOWN) {
|
||||
++touch_.count;
|
||||
} else if (event.type == SDL_FINGERUP) {
|
||||
touch_.count = std::max(0, touch_.count - 1);
|
||||
}
|
||||
|
||||
if (touch_.count == 2 && event.type == SDL_FINGERDOWN) {
|
||||
int w, h;
|
||||
SDL_GetWindowSize(window, &w, &h);
|
||||
SDL_TouchID tid = event.tfinger.touchId;
|
||||
int nf = SDL_GetNumTouchFingers(tid);
|
||||
if (nf >= 2) {
|
||||
SDL_Finger* f0 = SDL_GetTouchFinger(tid, 0);
|
||||
SDL_Finger* f1 = SDL_GetTouchFinger(tid, 1);
|
||||
float x0 = f0->x * w, x1 = f1->x * w;
|
||||
float dx = x1 - x0, dy = (f1->y - f0->y) * h;
|
||||
touch_.startDist = std::sqrt(dx * dx + dy * dy);
|
||||
touch_.lastDist = touch_.startDist;
|
||||
touch_.startCenterX = (x0 + x1) * 0.5f;
|
||||
touch_.lastCenterX = touch_.startCenterX;
|
||||
touch_.startLo = ui.viewLo;
|
||||
touch_.startHi = ui.viewHi;
|
||||
}
|
||||
}
|
||||
|
||||
if (touch_.count == 2 && event.type == SDL_FINGERMOTION) {
|
||||
int w, h;
|
||||
SDL_GetWindowSize(window, &w, &h);
|
||||
SDL_TouchID tid = event.tfinger.touchId;
|
||||
int nf = SDL_GetNumTouchFingers(tid);
|
||||
if (nf >= 2) {
|
||||
SDL_Finger* f0 = SDL_GetTouchFinger(tid, 0);
|
||||
SDL_Finger* f1 = SDL_GetTouchFinger(tid, 1);
|
||||
float x0 = f0->x * w, x1 = f1->x * w;
|
||||
float dx = x1 - x0, dy = (f1->y - f0->y) * h;
|
||||
float dist = std::sqrt(dx * dx + dy * dy);
|
||||
float centerX = (x0 + x1) * 0.5f;
|
||||
|
||||
if (touch_.startDist > 1.0f) {
|
||||
float span0 = touch_.startHi - touch_.startLo;
|
||||
float ratio = touch_.startDist / std::max(dist, 1.0f);
|
||||
float newSpan = std::clamp(span0 * ratio, 0.001f, 1.0f);
|
||||
|
||||
float panelW = wfSizeX > 0 ? wfSizeX : static_cast<float>(w);
|
||||
float panelX = wfPosX;
|
||||
float midFrac = (touch_.startCenterX - panelX) / panelW;
|
||||
float midView = touch_.startLo + midFrac * span0;
|
||||
|
||||
float panDelta = -(centerX - touch_.startCenterX) / panelW * newSpan;
|
||||
|
||||
float newLo = midView - midFrac * newSpan + panDelta;
|
||||
float newHi = newLo + newSpan;
|
||||
|
||||
if (newLo < 0.0f) { newHi -= newLo; newLo = 0.0f; }
|
||||
if (newHi > 1.0f) { newLo -= (newHi - 1.0f); newHi = 1.0f; }
|
||||
ui.viewLo = std::clamp(newLo, 0.0f, 1.0f);
|
||||
ui.viewHi = std::clamp(newHi, 0.0f, 1.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace baudmine
|
||||
72
src/ui/DisplayPanel.h
Normal file
72
src/ui/DisplayPanel.h
Normal file
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/Types.h"
|
||||
#include "ui/UIState.h"
|
||||
#include "ui/WaterfallDisplay.h"
|
||||
#include "ui/SpectrumDisplay.h"
|
||||
#include "ui/Cursors.h"
|
||||
#include "ui/Measurements.h"
|
||||
#include "ui/ColorMap.h"
|
||||
|
||||
#include <SDL.h>
|
||||
#include <vector>
|
||||
|
||||
namespace baudmine {
|
||||
|
||||
class AudioEngine;
|
||||
|
||||
class DisplayPanel {
|
||||
public:
|
||||
void renderSpectrum(AudioEngine& audio, UIState& ui,
|
||||
SpectrumDisplay& specDisplay, Cursors& cursors,
|
||||
Measurements& measurements);
|
||||
|
||||
void renderWaterfall(AudioEngine& audio, UIState& ui,
|
||||
WaterfallDisplay& waterfall, SpectrumDisplay& specDisplay,
|
||||
Cursors& cursors, Measurements& measurements,
|
||||
ColorMap& colorMap);
|
||||
|
||||
void renderHoverOverlay(const AudioEngine& audio, const UIState& ui,
|
||||
const Cursors& cursors, const SpectrumDisplay& specDisplay);
|
||||
|
||||
void handleTouch(const SDL_Event& event, UIState& ui, SDL_Window* window);
|
||||
|
||||
// Panel geometry (read by Application for layout)
|
||||
float specPosX = 0, specPosY = 0, specSizeX = 0, specSizeY = 0;
|
||||
float wfPosX = 0, wfPosY = 0, wfSizeX = 0, wfSizeY = 0;
|
||||
|
||||
enum class HoverPanel { None, Spectrum, Waterfall };
|
||||
HoverPanel hoverPanel = HoverPanel::None;
|
||||
float hoverWfTimeOff = 0.0f;
|
||||
|
||||
float spectrumFrac = 0.35f;
|
||||
bool draggingSplit = false;
|
||||
|
||||
// Returns true if split was just released (caller should save config).
|
||||
bool splitReleased();
|
||||
|
||||
private:
|
||||
void handleSpectrumInput(AudioEngine& audio, UIState& ui,
|
||||
SpectrumDisplay& specDisplay, Cursors& cursors,
|
||||
float posX, float posY, float sizeX, float sizeY);
|
||||
|
||||
struct TouchState {
|
||||
int count = 0;
|
||||
float startDist = 0.0f;
|
||||
float startLo = 0.0f;
|
||||
float startHi = 0.0f;
|
||||
float startCenterX = 0.0f;
|
||||
float lastCenterX = 0.0f;
|
||||
float lastDist = 0.0f;
|
||||
} touch_;
|
||||
|
||||
bool splitWasReleased_ = false;
|
||||
|
||||
// Scratch buffers
|
||||
std::vector<std::vector<float>> wfSpectraScratch_;
|
||||
std::vector<WaterfallChannelInfo> wfChInfoScratch_;
|
||||
std::vector<std::vector<float>> allSpectraScratch_;
|
||||
std::vector<ChannelStyle> stylesScratch_;
|
||||
};
|
||||
|
||||
} // namespace baudmine
|
||||
32
src/ui/UIState.h
Normal file
32
src/ui/UIState.h
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/Types.h"
|
||||
#include <imgui.h>
|
||||
|
||||
namespace baudmine {
|
||||
|
||||
// Shared display state accessed by both ControlPanel and DisplayPanel.
|
||||
struct UIState {
|
||||
float minDB = -120.0f;
|
||||
float maxDB = 0.0f;
|
||||
FreqScale freqScale = FreqScale::Linear;
|
||||
float viewLo = 0.0f;
|
||||
float viewHi = 0.5f;
|
||||
bool paused = false;
|
||||
|
||||
int waterfallChannel = 0;
|
||||
bool waterfallMultiCh = true;
|
||||
bool channelEnabled[kMaxChannels] = {true,true,true,true,true,true,true,true};
|
||||
ImVec4 channelColors[kMaxChannels] = {
|
||||
{0.20f, 0.90f, 0.30f, 1.0f}, // green
|
||||
{0.70f, 0.30f, 1.00f, 1.0f}, // purple
|
||||
{1.00f, 0.55f, 0.00f, 1.0f}, // orange
|
||||
{0.00f, 0.75f, 1.00f, 1.0f}, // cyan
|
||||
{1.00f, 0.25f, 0.25f, 1.0f}, // red
|
||||
{1.00f, 1.00f, 0.30f, 1.0f}, // yellow
|
||||
{0.50f, 0.80f, 0.50f, 1.0f}, // light green
|
||||
{0.80f, 0.50f, 0.80f, 1.0f}, // pink
|
||||
};
|
||||
};
|
||||
|
||||
} // namespace baudmine
|
||||
Reference in New Issue
Block a user