Files
baudmine/src/ui/Application.cpp
2026-03-27 01:30:10 +01:00

1974 lines
78 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "ui/Application.h"
#include "audio/FileSource.h"
#include <imgui.h>
#include <imgui_impl_sdl2.h>
#include <imgui_impl_opengl3.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#include <emscripten/html5.h>
#include <GLES2/gl2.h>
EM_JS(void, js_toggleFullscreen, (), {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
});
EM_JS(int, js_isFullscreen, (), {
return document.fullscreenElement ? 1 : 0;
});
EM_JS(float, js_devicePixelRatio, (), {
return window.devicePixelRatio || 1.0;
});
// SDL_CreateWindow sets inline width/height on the canvas which overrides
// the stylesheet's 100vw/100vh. Clear them once so CSS stays in control.
EM_JS(void, js_clearCanvasInlineSize, (), {
var c = document.getElementById('canvas');
if (c) { c.style.width = ''; c.style.height = ''; }
});
#else
#include <GL/gl.h>
#endif
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <cmath>
namespace baudmine {
Application::Application() = default;
void Application::syncCanvasSize() {
#ifdef __EMSCRIPTEN__
double cssW, cssH;
emscripten_get_element_css_size("#canvas", &cssW, &cssH);
float dpr = js_devicePixelRatio();
int targetW = static_cast<int>(cssW * dpr + 0.5);
int targetH = static_cast<int>(cssH * dpr + 0.5);
int curW, curH;
emscripten_get_canvas_element_size("#canvas", &curW, &curH);
if (curW != targetW || curH != targetH) {
// Set backing store + viewport to physical pixels.
// CSS display size is handled by the stylesheet (100vw × 100vh).
emscripten_set_canvas_element_size("#canvas", targetW, targetH);
glViewport(0, 0, targetW, targetH);
}
// Re-apply UI scale if devicePixelRatio changed (orientation, zoom, etc.)
if (std::abs(dpr - lastDpr_) > 0.01f) {
lastDpr_ = dpr;
float scale = (uiScale_ > 0.0f) ? uiScale_ : dpr;
applyUIScale(scale);
}
#endif
}
void Application::applyUIScale(float scale) {
scale = std::clamp(scale, 0.5f, 4.0f);
if (std::abs(scale - appliedScale_) < 0.01f) return;
appliedScale_ = scale;
// Snapshot the 1x base style once.
static ImGuiStyle baseStyle = [] {
ImGuiStyle s;
ImGui::StyleColorsDark(&s);
s.WindowRounding = 4.0f;
s.FrameRounding = 2.0f;
s.GrabRounding = 2.0f;
return s;
}();
// Determine framebuffer scale (e.g. 2x3x on HiDPI phones).
float fbScale = 1.0f;
int winW, winH, drawW, drawH;
SDL_GetWindowSize(window_, &winW, &winH);
SDL_GL_GetDrawableSize(window_, &drawW, &drawH);
if (winW > 0) fbScale = static_cast<float>(drawW) / winW;
logicalScale_ = scale / fbScale;
ImGuiIO& io = ImGui::GetIO();
// Rasterize the font at full physical resolution so the atlas has
// crisp glyphs, then tell ImGui to scale them back to logical size.
// Without this the 13px atlas gets bilinear-stretched to 13*dpr px.
io.Fonts->Clear();
ImFontConfig fc;
fc.SizePixels = std::max(8.0f, 13.0f * scale); // physical pixels
io.Fonts->AddFontDefault(&fc);
io.Fonts->Build();
ImGui_ImplOpenGL3_DestroyFontsTexture();
io.FontGlobalScale = 1.0f / fbScale; // display at logical size
// Restore base style, then scale from 1x.
ImGui::GetStyle() = baseStyle;
ImGui::GetStyle().ScaleAllSizes(logicalScale_);
}
void Application::requestUIScale(float scale) {
pendingScale_ = scale;
}
Application::~Application() {
shutdown();
}
bool Application::init(int argc, char** argv) {
// Parse command line: baudmine [file] [--format fmt] [--rate sr]
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--format" && i + 1 < argc) {
std::string fmt = argv[++i];
if (fmt == "f32") fileFormatIdx_ = 0;
if (fmt == "i16") fileFormatIdx_ = 1;
if (fmt == "u8") fileFormatIdx_ = 2;
if (fmt == "wav") fileFormatIdx_ = 3;
} else if (arg == "--rate" && i + 1 < argc) {
fileSampleRate_ = std::stof(argv[++i]);
} else if (arg == "--iq") {
settings_.isIQ = true;
} else if (arg[0] != '-') {
filePath_ = arg;
}
}
// SDL init
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
std::fprintf(stderr, "SDL_Init error: %s\n", SDL_GetError());
return false;
}
#ifdef __EMSCRIPTEN__
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
#else
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
#endif
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
window_ = SDL_CreateWindow("Baudmine Spectrum Analyzer",
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
1400, 900,
SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE |
SDL_WINDOW_ALLOW_HIGHDPI);
if (!window_) {
std::fprintf(stderr, "SDL_CreateWindow error: %s\n", SDL_GetError());
return false;
}
#ifdef __EMSCRIPTEN__
// SDL_CreateWindow sets inline width/height on the canvas element,
// overriding the stylesheet's 100vw/100vh. Clear them so CSS stays
// in control of display size while we manage the backing store.
js_clearCanvasInlineSize();
#endif
glContext_ = SDL_GL_CreateContext(window_);
SDL_GL_MakeCurrent(window_, glContext_);
SDL_GL_SetSwapInterval(1); // vsync
// ImGui init
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
ImGui::StyleColorsDark();
ImGuiStyle& style = ImGui::GetStyle();
style.WindowRounding = 4.0f;
style.FrameRounding = 2.0f;
style.GrabRounding = 2.0f;
ImGui_ImplSDL2_InitForOpenGL(window_, glContext_);
#ifdef __EMSCRIPTEN__
ImGui_ImplOpenGL3_Init("#version 100");
#else
ImGui_ImplOpenGL3_Init("#version 120");
#endif
// Enumerate audio devices
paDevices_ = MiniAudioSource::listInputDevices();
// Load saved config (overwrites defaults for FFT size, overlap, window, etc.)
loadConfig();
// Sync canvas to physical pixels before first frame (WASM)
syncCanvasSize();
// Apply DPI-aware UI scaling
{
float dpiScale = 1.0f;
#ifdef __EMSCRIPTEN__
dpiScale = js_devicePixelRatio();
lastDpr_ = dpiScale;
#else
float ddpi = 0;
if (SDL_GetDisplayDPI(0, &ddpi, nullptr, nullptr) == 0 && ddpi > 0)
dpiScale = ddpi / 96.0f;
#endif
float scale = (uiScale_ > 0.0f) ? uiScale_ : dpiScale;
applyUIScale(scale);
}
// Apply loaded settings
settings_.fftSize = kFFTSizes[fftSizeIdx_];
settings_.overlap = overlapPct_ / 100.0f;
settings_.window = static_cast<WindowType>(windowIdx_);
settings_.sampleRate = fileSampleRate_;
settings_.isIQ = false;
// Open source
if (!filePath_.empty()) {
InputFormat fmt;
switch (fileFormatIdx_) {
case 0: fmt = InputFormat::Float32IQ; settings_.isIQ = true; break;
case 1: fmt = InputFormat::Int16IQ; settings_.isIQ = true; break;
case 2: fmt = InputFormat::Uint8IQ; settings_.isIQ = true; break;
default: fmt = InputFormat::WAV; break;
}
openFile(filePath_, fmt, fileSampleRate_);
} else {
openPortAudio();
}
updateAnalyzerSettings();
running_ = true;
return true;
}
void Application::mainLoopStep() {
syncCanvasSize();
// Apply deferred UI scale (must happen outside the ImGui frame).
if (pendingScale_ > 0.0f) {
applyUIScale(pendingScale_);
pendingScale_ = 0.0f;
}
SDL_Event event;
while (SDL_PollEvent(&event)) {
ImGui_ImplSDL2_ProcessEvent(&event);
handleTouchEvent(event);
if (event.type == SDL_QUIT)
running_ = false;
if (event.type == SDL_KEYDOWN) {
auto key = event.key.keysym.sym;
#ifndef __EMSCRIPTEN__
if (key == SDLK_ESCAPE) running_ = false;
#endif
if (key == SDLK_SPACE) paused_ = !paused_;
if (key == SDLK_p) {
int pkCh = std::clamp(waterfallChannel_, 0,
totalNumSpectra() - 1);
cursors_.snapToPeak(getSpectrum(pkCh),
settings_.sampleRate, settings_.isIQ,
settings_.fftSize);
}
}
}
if (!paused_)
processAudio();
render();
}
#ifdef __EMSCRIPTEN__
static void emMainLoop(void* arg) {
static_cast<Application*>(arg)->mainLoopStep();
}
#endif
void Application::run() {
#ifdef __EMSCRIPTEN__
emscripten_set_main_loop_arg(emMainLoop, this, 0, true);
#else
while (running_) {
mainLoopStep();
}
#endif
}
void Application::shutdown() {
if (audioSource_) {
audioSource_->close();
audioSource_.reset();
}
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL2_Shutdown();
ImGui::DestroyContext();
if (glContext_) {
SDL_GL_DeleteContext(glContext_);
glContext_ = nullptr;
}
if (window_) {
SDL_DestroyWindow(window_);
window_ = nullptr;
}
SDL_Quit();
}
void Application::processAudio() {
if (!audioSource_) return;
int channels = audioSource_->channels();
// Read in hop-sized chunks, process up to a limited number of spectra per
// frame to avoid freezing the UI when a large backlog has accumulated.
size_t hopFrames = static_cast<size_t>(
settings_.fftSize * (1.0f - settings_.overlap));
if (hopFrames < 1) hopFrames = 1;
size_t framesToRead = hopFrames;
audioBuf_.resize(framesToRead * channels);
constexpr int kMaxSpectraPerFrame = 8;
int spectraThisFrame = 0;
// Process primary source.
while (spectraThisFrame < kMaxSpectraPerFrame) {
size_t framesRead = audioSource_->read(audioBuf_.data(), framesToRead);
if (framesRead == 0) break;
analyzer_.pushSamples(audioBuf_.data(), framesRead);
if (analyzer_.hasNewSpectrum()) {
++spectraThisFrame;
}
}
// Process extra devices independently (each at its own pace).
for (auto& ed : extraDevices_) {
int edCh = ed->source->channels();
const auto& edSettings = ed->analyzer.settings();
size_t edHop = static_cast<size_t>(edSettings.fftSize * (1.0f - edSettings.overlap));
if (edHop < 1) edHop = 1;
ed->audioBuf.resize(edHop * edCh);
int edSpectra = 0;
while (edSpectra < kMaxSpectraPerFrame) {
size_t framesRead = ed->source->read(ed->audioBuf.data(), edHop);
if (framesRead == 0) break;
ed->analyzer.pushSamples(ed->audioBuf.data(), framesRead);
if (ed->analyzer.hasNewSpectrum())
++edSpectra;
}
}
// Update waterfall / cursors / math using unified channel view.
// Only advance when the primary analyzer produced a spectrum (controls scroll rate).
if (spectraThisFrame > 0) {
computeMathChannels();
int nSpec = totalNumSpectra();
if (waterfallMultiCh_ && nSpec > 1) {
wfSpectraScratch_.clear();
wfChInfoScratch_.clear();
for (int ch = 0; ch < nSpec; ++ch) {
const auto& c = channelColors_[ch % kMaxChannels];
wfSpectraScratch_.push_back(getSpectrum(ch));
wfChInfoScratch_.push_back({c.x, c.y, c.z,
channelEnabled_[ch % kMaxChannels]});
}
for (size_t mi = 0; mi < mathChannels_.size(); ++mi) {
if (mathChannels_[mi].enabled && mathChannels_[mi].waterfall &&
mi < mathSpectra_.size()) {
const auto& c = mathChannels_[mi].color;
wfSpectraScratch_.push_back(mathSpectra_[mi]);
wfChInfoScratch_.push_back({c.x, c.y, c.z, true});
}
}
waterfall_.pushLineMulti(wfSpectraScratch_, wfChInfoScratch_, minDB_, maxDB_);
} else {
int wfCh = std::clamp(waterfallChannel_, 0, nSpec - 1);
waterfall_.pushLine(getSpectrum(wfCh), minDB_, maxDB_);
}
int curCh = std::clamp(waterfallChannel_, 0, nSpec - 1);
cursors_.update(getSpectrum(curCh),
settings_.sampleRate, settings_.isIQ, settings_.fftSize);
measurements_.update(getSpectrum(curCh),
settings_.sampleRate, settings_.isIQ, settings_.fftSize);
}
if (audioSource_->isEOF() && !audioSource_->isRealTime()) {
paused_ = true;
}
}
void Application::render() {
// Skip rendering entirely when the window is minimized — the drawable
// size is 0, which would create zero-sized GL textures and divide-by-zero
// in layout calculations.
if (SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED) {
SDL_Delay(16);
return;
}
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL2_NewFrame();
ImGui::NewFrame();
hoverPanel_ = HoverPanel::None;
// Full-screen layout
ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(viewport->WorkPos);
ImGui::SetNextWindowSize(viewport->WorkSize);
ImGui::Begin("##Main", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoBringToFrontOnFocus |
ImGuiWindowFlags_MenuBar);
// Menu bar
if (ImGui::BeginMenuBar()) {
// Sidebar toggle (leftmost)
if (ImGui::Button(showSidebar_ ? " << " : " >> ")) {
showSidebar_ = !showSidebar_;
saveConfig();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(showSidebar_ ? "Hide sidebar" : "Show sidebar");
ImGui::Separator();
if (ImGui::BeginMenu("File")) {
// ── File input ──
static char filePathBuf[512] = "";
if (filePath_.size() < sizeof(filePathBuf))
std::strncpy(filePathBuf, filePath_.c_str(), sizeof(filePathBuf) - 1);
ImGui::SetNextItemWidth(200);
if (ImGui::InputText("Path", filePathBuf, sizeof(filePathBuf)))
filePath_ = filePathBuf;
const char* formatNames[] = {"Float32 I/Q", "Int16 I/Q", "Uint8 I/Q", "WAV"};
ImGui::SetNextItemWidth(140);
ImGui::Combo("Format", &fileFormatIdx_, formatNames, 4);
ImGui::SetNextItemWidth(140);
ImGui::DragFloat("Sample Rate", &fileSampleRate_, 1000.0f, 1000.0f, 100e6f, "%.0f Hz");
ImGui::Checkbox("Loop", &fileLoop_);
if (ImGui::MenuItem("Open File")) {
InputFormat fmt;
switch (fileFormatIdx_) {
case 0: fmt = InputFormat::Float32IQ; break;
case 1: fmt = InputFormat::Int16IQ; break;
case 2: fmt = InputFormat::Uint8IQ; break;
default: fmt = InputFormat::WAV; break;
}
openFile(filePath_, fmt, fileSampleRate_);
updateAnalyzerSettings();
}
ImGui::Separator();
// ── Audio device ──
if (!paDevices_.empty()) {
if (ImGui::Checkbox("Multi-Device", &multiDeviceMode_)) {
// Switching modes: clear multi-select, re-open
std::memset(paDeviceSelected_, 0, sizeof(paDeviceSelected_));
if (!multiDeviceMode_) {
openPortAudio();
updateAnalyzerSettings();
saveConfig();
}
}
if (multiDeviceMode_) {
// Multi-device: checkboxes, each selected device = one channel.
ImGui::Text("Select devices (each = 1 channel):");
int maxDevs = std::min(static_cast<int>(paDevices_.size()), kMaxChannels);
bool changed = false;
for (int i = 0; i < maxDevs; ++i) {
if (ImGui::Checkbox(
(paDevices_[i].name + "##mdev" + std::to_string(i)).c_str(),
&paDeviceSelected_[i])) {
changed = true;
}
}
if (changed) {
openMultiDevice();
updateAnalyzerSettings();
saveConfig();
}
} else {
// Single-device mode: combo selector.
ImGui::Text("Audio Device");
std::vector<const char*> devNames;
for (auto& d : paDevices_) devNames.push_back(d.name.c_str());
ImGui::SetNextItemWidth(250);
if (ImGui::Combo("##device", &paDeviceIdx_, devNames.data(),
static_cast<int>(devNames.size()))) {
openPortAudio();
updateAnalyzerSettings();
saveConfig();
}
}
}
if (ImGui::MenuItem("Open Audio Device")) {
if (multiDeviceMode_)
openMultiDevice();
else
openPortAudio();
updateAnalyzerSettings();
}
ImGui::Separator();
if (ImGui::MenuItem("Quit", "Esc")) running_ = false;
ImGui::EndMenu();
}
if (ImGui::BeginMenu("View")) {
ImGui::MenuItem("Grid", nullptr, &specDisplay_.showGrid);
ImGui::MenuItem("Fill Spectrum", nullptr, &specDisplay_.fillSpectrum);
ImGui::Separator();
if (ImGui::MenuItem("VSync", nullptr, &vsync_)) {
SDL_GL_SetSwapInterval(vsync_ ? 1 : 0);
saveConfig();
}
if (ImGui::BeginMenu("UI scale")) {
static constexpr int kScales[] = {100, 150, 175, 200, 225, 250, 300};
int curPct = static_cast<int>(appliedScale_ * 100.0f + 0.5f);
if (ImGui::MenuItem("Auto", nullptr, uiScale_ == 0.0f)) {
uiScale_ = 0.0f;
float dpiScale = 1.0f;
#ifdef __EMSCRIPTEN__
dpiScale = js_devicePixelRatio();
#else
float ddpi = 0;
if (SDL_GetDisplayDPI(0, &ddpi, nullptr, nullptr) == 0 && ddpi > 0)
dpiScale = ddpi / 96.0f;
#endif
requestUIScale(dpiScale);
saveConfig();
}
for (int s : kScales) {
char label[16];
std::snprintf(label, sizeof(label), "%d%%", s);
if (ImGui::MenuItem(label, nullptr, uiScale_ > 0.0f && std::abs(curPct - s) <= 2)) {
uiScale_ = s / 100.0f;
requestUIScale(uiScale_);
saveConfig();
}
}
ImGui::EndMenu();
}
ImGui::EndMenu();
}
#ifndef IMGUI_DISABLE_DEBUG_TOOLS
if (ImGui::BeginMenu("Debug")) {
ImGui::MenuItem("Metrics/Debugger", nullptr, &showMetricsWindow_);
ImGui::MenuItem("Debug Log", nullptr, &showDebugLog_);
ImGui::MenuItem("Stack Tool", nullptr, &showStackTool_);
ImGui::MenuItem("Demo Window", nullptr, &showDemoWindow_);
ImGui::Separator();
ImGui::Text("%.1f FPS (%.3f ms)", ImGui::GetIO().Framerate,
1000.0f / ImGui::GetIO().Framerate);
ImGui::EndMenu();
}
#endif
#ifdef __EMSCRIPTEN__
if (ImGui::SmallButton(js_isFullscreen() ? "Exit Fullscreen" : "Fullscreen")) {
js_toggleFullscreen();
}
#endif
// Right-aligned status in menu bar
{
float barW = ImGui::GetWindowWidth();
char statusBuf[128];
std::snprintf(statusBuf, sizeof(statusBuf), "%.0f Hz | %d pt | %.1f Hz/bin | %.0f FPS",
settings_.sampleRate, settings_.fftSize,
settings_.sampleRate / settings_.fftSize,
ImGui::GetIO().Framerate);
ImVec2 textSz = ImGui::CalcTextSize(statusBuf);
ImGui::SameLine(barW - textSz.x - 16);
ImGui::TextDisabled("%s", statusBuf);
}
ImGui::EndMenuBar();
}
// Layout
float totalW = ImGui::GetContentRegionAvail().x;
float contentH = ImGui::GetContentRegionAvail().y;
float controlW = showSidebar_ ? 270.0f * logicalScale_ : 0.0f;
float contentW = totalW - (showSidebar_ ? controlW + 8 : 0);
// Control panel (sidebar)
if (showSidebar_) {
ImGui::BeginChild("Controls", {controlW, contentH}, true);
renderControlPanel();
ImGui::EndChild();
ImGui::SameLine();
}
// Waterfall (top) + Spectrum (bottom) with draggable splitter
ImGui::BeginChild("Display", {contentW, contentH}, false);
{
constexpr float kSplitterH = 6.0f;
renderWaterfallPanel();
// ── 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;
// Dragging down = more waterfall = less spectrum
spectrumFrac_ -= dy / contentH;
spectrumFrac_ = std::clamp(spectrumFrac_, 0.1f, 0.9f);
draggingSplit_ = true;
} else if (draggingSplit_) {
draggingSplit_ = false;
saveConfig();
}
// 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);
renderSpectrumPanel();
// ── 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 in spectrum area only
dlp->AddLine({hx, specPosY_}, {hx, specPosY_ + specSizeY_}, hoverCol, 1.0f);
// Frequency label at top of waterfall
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;
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);
// ── Hover info (right side of spectrum/waterfall) ──
{
// Use bin center for frequency
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;
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, -hoverWfTimeOffset_);
} else {
fmtFreq(hoverBuf, sizeof(hoverBuf), binCenterFreq);
}
// Right-align the text
ImU32 hoverTextCol = IM_COL32(100, 230, 130, 240);
float rightEdge = specPosX_ + specSizeX_ - 8;
float hy2 = specPosY_ + 4;
ImVec2 hSz = ImGui::CalcTextSize(hoverBuf);
dlp->AddText({rightEdge - hSz.x, hy2}, hoverTextCol, hoverBuf);
}
}
}
ImGui::EndChild();
ImGui::End();
#ifndef IMGUI_DISABLE_DEBUG_TOOLS
// ImGui debug windows
if (showDemoWindow_) ImGui::ShowDemoWindow(&showDemoWindow_);
if (showMetricsWindow_) ImGui::ShowMetricsWindow(&showMetricsWindow_);
if (showDebugLog_) ImGui::ShowDebugLogWindow(&showDebugLog_);
if (showStackTool_) ImGui::ShowIDStackToolWindow(&showStackTool_);
#endif
// Render
ImGui::Render();
int displayW, displayH;
SDL_GL_GetDrawableSize(window_, &displayW, &displayH);
glViewport(0, 0, displayW, displayH);
glClearColor(0.08f, 0.08f, 0.10f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
SDL_GL_SwapWindow(window_);
}
void Application::renderControlPanel() {
// ── Playback ──
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3.0f;
if (ImGui::Button(paused_ ? "Resume" : "Pause", {btnW, 0}))
paused_ = !paused_;
ImGui::SameLine();
if (ImGui::Button("Clear", {btnW, 0})) {
analyzer_.clearHistory();
for (auto& ed : extraDevices_) ed->analyzer.clearHistory();
}
ImGui::SameLine();
if (ImGui::Button("Peak", {btnW, 0})) {
int pkCh = std::clamp(waterfallChannel_, 0, totalNumSpectra() - 1);
cursors_.snapToPeak(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)) {
settings_.fftSize = kFFTSizes[fftSizeIdx_];
updateAnalyzerSettings();
saveConfig();
}
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))) {
settings_.window = static_cast<WindowType>(windowIdx_);
updateAnalyzerSettings();
saveConfig();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Window Function");
if (settings_.window == WindowType::Kaiser) {
ImGui::SetNextItemWidth(-1);
if (ImGui::SliderFloat("##kaiser", &settings_.kaiserBeta, 0.0f, 20.0f, "Kaiser: %.1f"))
updateAnalyzerSettings();
}
// 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);
settings_.overlap = overlapPct_ / 100.0f;
updateAnalyzerSettings();
saveConfig();
}
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", &minDB_, &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 = (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(viewLo_);
float bfHi = screenToBin(viewHi_);
bool newLog = !isLog;
freqScale_ = newLog ? FreqScale::Logarithmic : FreqScale::Linear;
viewLo_ = std::clamp(binToScreen(bfLo, newLog), 0.0f, 1.0f);
viewHi_ = std::clamp(binToScreen(bfHi, newLog), 0.0f, 1.0f);
if (viewHi_ <= viewLo_) { viewLo_ = 0.0f; viewHi_ = 1.0f; }
saveConfig();
}
}
if (!canLog && ImGui::IsItemHovered())
ImGui::SetTooltip("Log scale not available in I/Q mode");
}
{
float span = viewHi_ - 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;
viewLo_ = 0.0f;
viewHi_ = std::clamp(newSpan, 0.0f, 1.0f);
}
ImGui::SameLine();
if (ImGui::SmallButton("Reset##zoom")) {
viewLo_ = 0.0f;
viewHi_ = 0.5f;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Reset to 2x zoom");
}
}
// ── Channels ──
ImGui::Spacing();
{
int nCh = totalNumSpectra();
bool isMulti = waterfallMultiCh_ && nCh > 1;
// Header with inline Single/Multi toggle
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})) {
waterfallMultiCh_ = !waterfallMultiCh_;
}
}
if (headerOpen) {
if (isMulti) {
// Multi-channel: per-channel colors and enable
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", &channelEnabled_[ch]);
ImGui::SameLine();
ImGui::ColorEdit3(defaultNames[ch], &channelColors_[ch].x,
ImGuiColorEditFlags_NoInputs);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", getDeviceName(ch));
ImGui::PopID();
}
} else {
// Single-channel: color map + channel selector
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_);
saveConfig();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Color Map");
if (nCh > 1) {
ImGui::SetNextItemWidth(-1);
if (ImGui::SliderInt("##wfch", &waterfallChannel_, 0, nCh - 1))
waterfallChannel_ = std::clamp(waterfallChannel_, 0, nCh - 1);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Waterfall Channel");
}
}
}
}
// ── Math ──
ImGui::Spacing();
{
float btnW = 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 - btnW - 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 - btnW + ImGui::GetStyle().FramePadding.x);
if (ImGui::Button("+##addmath", {btnW, 0})) {
int nPhys = totalNumSpectra();
MathChannel mc;
mc.op = MathOp::Subtract;
mc.sourceX = 0;
mc.sourceY = std::min(1, nPhys - 1);
mc.color = {1.0f, 1.0f, 0.5f, 1.0f};
mathChannels_.push_back(mc);
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Add math channel");
if (mathOpen) {
renderMathPanel();
}
}
// ── Cursors ──
ImGui::Spacing();
{
float btnW = 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 - btnW - 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 - btnW + 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) saveConfig();
}
}
// ── 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)
saveConfig();
}
}
// ── Status (bottom) ──
ImGui::Separator();
ImGui::TextDisabled("Mode: %s", settings_.isIQ ? "I/Q"
: (settings_.numChannels > 1 ? "Multi-ch" : "Real"));
}
void Application::renderSpectrumPanel() {
float availW = ImGui::GetContentRegionAvail().x;
// Spectrum is at the bottom — use all remaining height after waterfall + splitter.
float specH = ImGui::GetContentRegionAvail().y;
ImVec2 pos = ImGui::GetCursorScreenPos();
specPosX_ = pos.x;
specPosY_ = pos.y;
specSizeX_ = availW;
specSizeY_ = specH;
// Build per-channel styles and combine physical + math spectra.
int nPhys = totalNumSpectra();
int nMath = static_cast<int>(mathSpectra_.size());
allSpectraScratch_.clear();
stylesScratch_.clear();
// Physical channels (skip disabled ones).
for (int ch = 0; ch < nPhys; ++ch) {
if (!channelEnabled_[ch % kMaxChannels]) continue;
allSpectraScratch_.push_back(getSpectrum(ch));
const auto& c = 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)});
}
// Math channels.
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.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)});
}
}
specDisplay_.updatePeakHold(allSpectraScratch_);
specDisplay_.draw(allSpectraScratch_, stylesScratch_, minDB_, maxDB_,
settings_.sampleRate, settings_.isIQ, freqScale_,
specPosX_, specPosY_, specSizeX_, specSizeY_,
viewLo_, viewHi_);
cursors_.draw(specDisplay_, specPosX_, specPosY_, specSizeX_, specSizeY_,
settings_.sampleRate, settings_.isIQ, freqScale_, minDB_, maxDB_,
viewLo_, viewHi_);
measurements_.draw(specDisplay_, specPosX_, specPosY_, specSizeX_, specSizeY_,
settings_.sampleRate, settings_.isIQ, freqScale_, minDB_, maxDB_,
viewLo_, viewHi_);
handleSpectrumInput(specPosX_, specPosY_, specSizeX_, specSizeY_);
ImGui::Dummy({availW, specH});
}
void Application::renderWaterfallPanel() {
float availW = ImGui::GetContentRegionAvail().x;
// Waterfall is at the top — compute height from the split fraction.
constexpr float kSplitterH = 6.0f;
float parentH = ImGui::GetContentRegionAvail().y;
float availH = (parentH - kSplitterH) * (1.0f - spectrumFrac_);
// History depth must be >= panel height for 1:1 pixel mapping.
// Only recreate when bin count or needed height actually changes.
int neededH = std::max(1024, static_cast<int>(availH) + 1);
int binCount = std::max(1, analyzer_.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();
// The newest row was just written at currentRow()+1 (mod h) — but
// advanceRow already decremented, so currentRow() IS the newest.
int screenRows = std::min(static_cast<int>(availH), h);
// Newest row index in the circular buffer.
int newestRow = (waterfall_.currentRow() + 1) % h;
// Render 1:1 (one texture row = one screen pixel), bottom-aligned,
// newest line at bottom, scrolling upward.
//
// We flip the V coordinates (v1 before v0) so that the vertical
// direction is reversed: newest at the bottom of the draw region.
float rowToV = 1.0f / h;
bool logMode = (freqScale_ == FreqScale::Logarithmic && !settings_.isIQ);
// drawSpan renders rows [rowStart..rowStart+rowCount) but with
// flipped V so oldest is at top and newest at bottom.
auto drawSpan = [&](int rowStart, int rowCount, float yStart, float spanH) {
float v0 = rowStart * rowToV;
float v1 = (rowStart + rowCount) * rowToV;
// Flip: swap v0 and v1 so texture is vertically inverted
if (!logMode) {
dl->AddImage(texID,
{pos.x, yStart},
{pos.x + availW, yStart + spanH},
{viewLo_, v1}, {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 = 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, v1}, {uR, v0});
}
}
};
// From newestRow, walk forward (increasing index mod h) for
// screenRows steps to cover newest→oldest.
// With V-flip, oldest rows render at the top, newest at the bottom.
float pxPerRow = availH / static_cast<float>(screenRows);
if (newestRow + screenRows <= h) {
drawSpan(newestRow, screenRows, pos.y, availH);
} else {
// Wrap-around: two spans. Because we flip V, the second span
// (wrap-around, containing older rows) goes at the TOP.
int firstCount = h - newestRow; // rows newestRow..h-1
int secondCount = screenRows - firstCount; // rows 0..secondCount-1
// Second span (older, wraps to index 0) at top
float secondH = secondCount * pxPerRow;
if (secondCount > 0)
drawSpan(0, secondCount, pos.y, secondH);
// First span (newer, includes newestRow) at bottom
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;
// 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;
float vf = viewLo_ + frac * (viewHi_ - 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);
}
// Store waterfall geometry for cross-panel cursor drawing.
wfPosX_ = pos.x; wfPosY_ = pos.y; wfSizeX_ = availW; wfSizeY_ = availH;
measurements_.drawWaterfall(specDisplay_, wfPosX_, wfPosY_, wfSizeX_, wfSizeY_,
settings_.sampleRate, settings_.isIQ, freqScale_,
viewLo_, viewHi_, screenRows, analyzer_.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;
// Hover cursor from waterfall
if (inWaterfall) {
hoverPanel_ = HoverPanel::Waterfall;
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);
// Time offset: bottom = newest (0s), top = oldest
float yFrac = 1.0f - (my - pos.y) / availH; // 0 at bottom, 1 at top
int hopSamples = static_cast<int>(settings_.fftSize * (1.0f - settings_.overlap));
if (hopSamples < 1) hopSamples = 1;
double secondsPerLine = static_cast<double>(hopSamples) / settings_.sampleRate;
hoverWfTimeOffset_ = static_cast<float>(yFrac * screenRows * secondsPerLine);
int curCh = std::clamp(waterfallChannel_, 0, totalNumSpectra() - 1);
const auto& spec = getSpectrum(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});
}
void Application::handleTouchEvent(const SDL_Event& event) {
if (event.type == SDL_FINGERDOWN) {
++touch_.count;
} else if (event.type == SDL_FINGERUP) {
touch_.count = std::max(0, touch_.count - 1);
}
// Two-finger gesture start: snapshot state
if (touch_.count == 2 && event.type == SDL_FINGERDOWN) {
int w, h;
SDL_GetWindowSize(window_, &w, &h);
// Get both finger positions from SDL touch API
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 = viewLo_;
touch_.startHi = viewHi_;
}
}
// Two-finger motion: pinch + pan
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) {
// Zoom: scale the span by start/current distance ratio
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);
// Anchor zoom at the initial midpoint (in view-fraction space)
float panelW = wfSizeX_ > 0 ? wfSizeX_ : static_cast<float>(w);
float panelX = wfPosX_;
float midFrac = (touch_.startCenterX - panelX) / panelW;
float midView = touch_.startLo + midFrac * span0;
// Pan: shift by finger midpoint movement
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; }
viewLo_ = std::clamp(newLo, 0.0f, 1.0f);
viewHi_ = std::clamp(newHi, 0.0f, 1.0f);
}
}
}
}
void Application::handleSpectrumInput(float posX, float posY,
float sizeX, float sizeY) {
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;
// Update hover cursor
double freq = specDisplay_.screenXToFreq(mx, posX, sizeX,
settings_.sampleRate,
settings_.isIQ, freqScale_,
viewLo_, viewHi_);
float dB = specDisplay_.screenYToDB(my, posY, sizeY, minDB_, maxDB_);
// Find closest bin
int bins = analyzer_.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(waterfallChannel_, 0, totalNumSpectra() - 1);
const auto& spec = getSpectrum(curCh);
if (!spec.empty()) {
dB = spec[bin];
cursors_.hover = {true, freq, dB, bin};
}
// Left drag: cursor A
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
int cBin = cursors_.snapToPeaks ? cursors_.findLocalPeak(spec, bin, 10) : bin;
double cFreq = analyzer_.binToFreq(cBin);
cursors_.setCursorA(cFreq, spec[cBin], cBin);
}
// Right drag: cursor B
if (ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
int cBin = cursors_.snapToPeaks ? cursors_.findLocalPeak(spec, bin, 10) : bin;
double cFreq = analyzer_.binToFreq(cBin);
cursors_.setCursorB(cFreq, spec[cBin], cBin);
}
{
// 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 {
// Only clear hover if waterfall didn't already set it this frame
if (hoverPanel_ != HoverPanel::Waterfall) {
hoverPanel_ = HoverPanel::None;
cursors_.hover.active = false;
}
}
}
void Application::openPortAudio() {
if (audioSource_) audioSource_->close();
extraDevices_.clear();
int deviceIdx = -1;
double sr = 48000.0;
if (paDeviceIdx_ >= 0 && paDeviceIdx_ < static_cast<int>(paDevices_.size())) {
deviceIdx = paDevices_[paDeviceIdx_].index;
sr = paDevices_[paDeviceIdx_].defaultSampleRate;
}
// Request stereo (or max available) so we can show per-channel spectra.
int reqCh = 2;
if (paDeviceIdx_ >= 0 && paDeviceIdx_ < static_cast<int>(paDevices_.size()))
reqCh = std::min(paDevices_[paDeviceIdx_].maxInputChannels, kMaxChannels);
if (reqCh < 1) reqCh = 1;
auto src = std::make_unique<MiniAudioSource>(sr, reqCh, deviceIdx);
if (src->open()) {
audioSource_ = std::move(src);
settings_.sampleRate = audioSource_->sampleRate();
settings_.isIQ = false;
settings_.numChannels = audioSource_->channels();
} else {
std::fprintf(stderr, "Failed to open audio device\n");
}
}
void Application::openMultiDevice() {
if (audioSource_) audioSource_->close();
extraDevices_.clear();
// Collect selected device indices.
std::vector<int> selected;
int maxDevs = std::min(static_cast<int>(paDevices_.size()), kMaxChannels);
for (int i = 0; i < maxDevs; ++i) {
if (paDeviceSelected_[i])
selected.push_back(i);
}
if (selected.empty()) return;
// First selected device becomes the primary source.
{
int idx = selected[0];
double sr = paDevices_[idx].defaultSampleRate;
int reqCh = std::min(paDevices_[idx].maxInputChannels, kMaxChannels);
if (reqCh < 1) reqCh = 1;
auto src = std::make_unique<MiniAudioSource>(sr, reqCh, paDevices_[idx].index);
if (src->open()) {
audioSource_ = std::move(src);
settings_.sampleRate = audioSource_->sampleRate();
settings_.isIQ = false;
settings_.numChannels = audioSource_->channels();
} else {
std::fprintf(stderr, "Failed to open primary device %s\n",
paDevices_[idx].name.c_str());
return;
}
}
// Remaining selected devices become extra sources, each with its own analyzer.
int totalCh = settings_.numChannels;
for (size_t s = 1; s < selected.size() && totalCh < kMaxChannels; ++s) {
int idx = selected[s];
double sr = paDevices_[idx].defaultSampleRate;
int reqCh = std::min(paDevices_[idx].maxInputChannels, kMaxChannels - totalCh);
if (reqCh < 1) reqCh = 1;
auto src = std::make_unique<MiniAudioSource>(sr, reqCh, paDevices_[idx].index);
if (src->open()) {
auto ed = std::make_unique<ExtraDevice>();
ed->source = std::move(src);
// Configure analyzer with same FFT settings but this device's params.
AnalyzerSettings es = settings_;
es.sampleRate = ed->source->sampleRate();
es.numChannels = ed->source->channels();
es.isIQ = false;
ed->analyzer.configure(es);
totalCh += ed->source->channels();
extraDevices_.push_back(std::move(ed));
} else {
std::fprintf(stderr, "Failed to open extra device %s\n",
paDevices_[idx].name.c_str());
}
}
}
int Application::totalNumSpectra() const {
int n = analyzer_.numSpectra();
for (auto& ed : extraDevices_)
n += ed->analyzer.numSpectra();
return n;
}
const std::vector<float>& Application::getSpectrum(int globalCh) const {
int n = analyzer_.numSpectra();
if (globalCh < n)
return analyzer_.channelSpectrum(globalCh);
globalCh -= n;
for (auto& ed : extraDevices_) {
int en = ed->analyzer.numSpectra();
if (globalCh < en)
return ed->analyzer.channelSpectrum(globalCh);
globalCh -= en;
}
return analyzer_.channelSpectrum(0); // fallback
}
const std::vector<std::complex<float>>& Application::getComplex(int globalCh) const {
int n = analyzer_.numSpectra();
if (globalCh < n)
return analyzer_.channelComplex(globalCh);
globalCh -= n;
for (auto& ed : extraDevices_) {
int en = ed->analyzer.numSpectra();
if (globalCh < en)
return ed->analyzer.channelComplex(globalCh);
globalCh -= en;
}
return analyzer_.channelComplex(0); // fallback
}
const char* Application::getDeviceName(int globalCh) const {
// Primary device channels.
int n = analyzer_.numSpectra();
if (globalCh < n) {
if (paDeviceIdx_ >= 0 && paDeviceIdx_ < static_cast<int>(paDevices_.size()))
return paDevices_[paDeviceIdx_].name.c_str();
// In multi-device mode the primary is the first selected device.
for (int i = 0; i < static_cast<int>(paDevices_.size()); ++i)
if (paDeviceSelected_[i]) return paDevices_[i].name.c_str();
return "Audio Device";
}
globalCh -= n;
// Walk extra devices to find which one owns this channel.
int devSel = 0;
for (int i = 0; i < static_cast<int>(paDevices_.size()) && i < kMaxChannels; ++i) {
if (!paDeviceSelected_[i]) continue;
++devSel;
if (devSel <= 1) continue; // skip primary (already handled above)
int edIdx = devSel - 2;
if (edIdx < static_cast<int>(extraDevices_.size())) {
int en = extraDevices_[edIdx]->analyzer.numSpectra();
if (globalCh < en)
return paDevices_[i].name.c_str();
globalCh -= en;
}
}
return "Audio Device";
}
void Application::openFile(const std::string& path, InputFormat format, double sampleRate) {
if (audioSource_) audioSource_->close();
extraDevices_.clear();
bool isIQ = (format != InputFormat::WAV);
auto src = std::make_unique<FileSource>(path, format, sampleRate, fileLoop_);
if (src->open()) {
settings_.sampleRate = src->sampleRate();
settings_.isIQ = isIQ;
settings_.numChannels = isIQ ? 1 : src->channels();
audioSource_ = std::move(src);
fileSampleRate_ = static_cast<float>(settings_.sampleRate);
} else {
std::fprintf(stderr, "Failed to open file: %s\n", path.c_str());
}
}
void Application::updateAnalyzerSettings() {
int oldFFTSize = settings_.fftSize;
bool oldIQ = settings_.isIQ;
int oldNCh = settings_.numChannels;
settings_.fftSize = kFFTSizes[fftSizeIdx_];
settings_.overlap = overlapPct_ / 100.0f;
settings_.window = static_cast<WindowType>(windowIdx_);
analyzer_.configure(settings_);
// Keep extra device analyzers in sync with FFT/overlap/window settings.
for (auto& ed : extraDevices_) {
AnalyzerSettings es = settings_;
es.sampleRate = ed->source->sampleRate();
es.numChannels = ed->source->channels();
es.isIQ = false;
ed->analyzer.configure(es);
}
bool sizeChanged = settings_.fftSize != oldFFTSize ||
settings_.isIQ != oldIQ ||
settings_.numChannels != oldNCh;
if (sizeChanged) {
// Drain any stale audio data from the ring buffer so a backlog from
// the reconfigure doesn't flood the new analyzer.
if (audioSource_ && audioSource_->isRealTime()) {
int channels = audioSource_->channels();
std::vector<float> drain(4096 * channels);
while (audioSource_->read(drain.data(), 4096) > 0) {}
}
for (auto& ed : extraDevices_) {
if (ed->source && ed->source->isRealTime()) {
int ch = ed->source->channels();
std::vector<float> drain(4096 * ch);
while (ed->source->read(drain.data(), 4096) > 0) {}
}
}
// Invalidate cursor bin indices — they refer to the old FFT size.
cursors_.cursorA.active = false;
cursors_.cursorB.active = false;
// Re-init waterfall texture so the old image from a different FFT
// size doesn't persist.
int reinitH = std::max(1024, waterfall_.height());
int binCount2 = std::max(1, analyzer_.spectrumSize());
waterfall_.init(binCount2, reinitH);
}
}
// ── Math channels ────────────────────────────────────────────────────────────
void Application::computeMathChannels() {
int nPhys = totalNumSpectra();
int specSz = analyzer_.spectrumSize();
mathSpectra_.resize(mathChannels_.size());
for (size_t mi = 0; mi < mathChannels_.size(); ++mi) {
const auto& mc = mathChannels_[mi];
auto& out = mathSpectra_[mi];
out.resize(specSz);
if (!mc.enabled) {
std::fill(out.begin(), out.end(), -200.0f);
continue;
}
int sx = std::clamp(mc.sourceX, 0, nPhys - 1);
int sy = std::clamp(mc.sourceY, 0, nPhys - 1);
const auto& xDB = getSpectrum(sx);
const auto& yDB = getSpectrum(sy);
const auto& xC = getComplex(sx);
const auto& yC = getComplex(sy);
for (int i = 0; i < specSz; ++i) {
float val = -200.0f;
switch (mc.op) {
// ── Unary ──
case MathOp::Negate:
val = -xDB[i];
break;
case MathOp::Absolute:
val = std::abs(xDB[i]);
break;
case MathOp::Square:
val = 2.0f * xDB[i];
break;
case MathOp::Cube:
val = 3.0f * xDB[i];
break;
case MathOp::Sqrt:
val = 0.5f * xDB[i];
break;
case MathOp::Log: {
// log10 of linear magnitude, back to dB-like scale.
float lin = std::pow(10.0f, xDB[i] / 10.0f);
float l = std::log10(lin + 1e-30f);
val = 10.0f * l; // keep in dB-like range
break;
}
// ── Binary ──
case MathOp::Add: {
float lx = std::pow(10.0f, xDB[i] / 10.0f);
float ly = std::pow(10.0f, yDB[i] / 10.0f);
float s = lx + ly;
val = (s > 1e-20f) ? 10.0f * std::log10(s) : -200.0f;
break;
}
case MathOp::Subtract: {
float lx = std::pow(10.0f, xDB[i] / 10.0f);
float ly = std::pow(10.0f, yDB[i] / 10.0f);
float d = std::abs(lx - ly);
val = (d > 1e-20f) ? 10.0f * std::log10(d) : -200.0f;
break;
}
case MathOp::Multiply:
val = xDB[i] + yDB[i];
break;
case MathOp::Phase: {
if (i < static_cast<int>(xC.size()) &&
i < static_cast<int>(yC.size())) {
auto cross = xC[i] * std::conj(yC[i]);
float deg = std::atan2(cross.imag(), cross.real())
* (180.0f / 3.14159265f);
// Map [-180, 180] degrees into the dB display range
// so it's visible on the plot.
val = deg;
}
break;
}
case MathOp::CrossCorr: {
if (i < static_cast<int>(xC.size()) &&
i < static_cast<int>(yC.size())) {
auto cross = xC[i] * std::conj(yC[i]);
float mag2 = std::norm(cross);
val = (mag2 > 1e-20f) ? 10.0f * std::log10(mag2) : -200.0f;
}
break;
}
default: break;
}
out[i] = val;
}
}
}
void Application::renderMathPanel() {
int nPhys = totalNumSpectra();
// Build source channel name list.
static const char* chNames[] = {
"Ch 0 (L)", "Ch 1 (R)", "Ch 2", "Ch 3", "Ch 4", "Ch 5", "Ch 6", "Ch 7"
};
// List existing math channels.
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.x, ImGuiColorEditFlags_NoInputs);
ImGui::SameLine();
// Operation combo.
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));
// Source X.
ImGui::SetNextItemWidth(80);
ImGui::Combo("X", &mc.sourceX, chNames, std::min(nPhys, kMaxChannels));
// Source Y (only for binary ops).
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);
}
void Application::loadConfig() {
config_.load();
fftSizeIdx_ = config_.getInt("fft_size_idx", fftSizeIdx_);
overlapPct_ = config_.getFloat("overlap_pct", overlapPct_);
windowIdx_ = config_.getInt("window_idx", windowIdx_);
colorMapIdx_ = config_.getInt("colormap_idx", colorMapIdx_);
minDB_ = config_.getFloat("min_db", minDB_);
maxDB_ = config_.getFloat("max_db", maxDB_);
int fs = config_.getInt("freq_scale", static_cast<int>(freqScale_));
freqScale_ = static_cast<FreqScale>(fs);
vsync_ = config_.getBool("vsync", vsync_);
uiScale_ = config_.getFloat("ui_scale", uiScale_);
spectrumFrac_ = config_.getFloat("spectrum_frac", spectrumFrac_);
showSidebar_ = config_.getBool("show_sidebar", showSidebar_);
specDisplay_.peakHoldEnable = config_.getBool("peak_hold", specDisplay_.peakHoldEnable);
specDisplay_.peakHoldDecay = config_.getFloat("peak_hold_decay", specDisplay_.peakHoldDecay);
cursors_.snapToPeaks = config_.getBool("snap_to_peaks", cursors_.snapToPeaks);
measurements_.traceMinFreq = config_.getFloat("trace_min_freq", measurements_.traceMinFreq);
measurements_.traceMaxFreq = config_.getFloat("trace_max_freq", measurements_.traceMaxFreq);
// Clamp
fftSizeIdx_ = std::clamp(fftSizeIdx_, 0, kNumFFTSizes - 1);
windowIdx_ = std::clamp(windowIdx_, 0, static_cast<int>(WindowType::Count) - 1);
colorMapIdx_ = std::clamp(colorMapIdx_, 0, static_cast<int>(ColorMapType::Count) - 1);
spectrumFrac_ = std::clamp(spectrumFrac_, 0.1f, 0.9f);
// Restore device selection.
multiDeviceMode_ = config_.getBool("multi_device", false);
std::string devName = config_.getString("device_name", "");
if (!devName.empty()) {
for (int i = 0; i < static_cast<int>(paDevices_.size()); ++i) {
if (paDevices_[i].name == devName) {
paDeviceIdx_ = i;
break;
}
}
}
// Restore multi-device selections from comma-separated device names.
std::memset(paDeviceSelected_, 0, sizeof(paDeviceSelected_));
std::string multiNames = config_.getString("multi_device_names", "");
if (!multiNames.empty()) {
size_t pos = 0;
while (pos < multiNames.size()) {
size_t comma = multiNames.find(',', pos);
if (comma == std::string::npos) comma = multiNames.size();
std::string name = multiNames.substr(pos, comma - pos);
for (int i = 0; i < std::min(static_cast<int>(paDevices_.size()), kMaxChannels); ++i) {
if (paDevices_[i].name == name)
paDeviceSelected_[i] = true;
}
pos = comma + 1;
}
}
// Apply
settings_.fftSize = kFFTSizes[fftSizeIdx_];
settings_.overlap = overlapPct_ / 100.0f;
settings_.window = static_cast<WindowType>(windowIdx_);
colorMap_.setType(static_cast<ColorMapType>(colorMapIdx_));
SDL_GL_SetSwapInterval(vsync_ ? 1 : 0);
}
void Application::saveConfig() const {
Config cfg;
cfg.setInt("fft_size_idx", fftSizeIdx_);
cfg.setFloat("overlap_pct", overlapPct_);
cfg.setInt("window_idx", windowIdx_);
cfg.setInt("colormap_idx", colorMapIdx_);
cfg.setFloat("min_db", minDB_);
cfg.setFloat("max_db", maxDB_);
cfg.setInt("freq_scale", static_cast<int>(freqScale_));
cfg.setBool("vsync", vsync_);
cfg.setFloat("ui_scale", uiScale_);
cfg.setFloat("spectrum_frac", spectrumFrac_);
cfg.setBool("show_sidebar", showSidebar_);
cfg.setBool("peak_hold", specDisplay_.peakHoldEnable);
cfg.setFloat("peak_hold_decay", specDisplay_.peakHoldDecay);
cfg.setBool("snap_to_peaks", cursors_.snapToPeaks);
cfg.setFloat("trace_min_freq", measurements_.traceMinFreq);
cfg.setFloat("trace_max_freq", measurements_.traceMaxFreq);
if (paDeviceIdx_ >= 0 && paDeviceIdx_ < static_cast<int>(paDevices_.size()))
cfg.setString("device_name", paDevices_[paDeviceIdx_].name);
cfg.setBool("multi_device", multiDeviceMode_);
// Save multi-device selections as comma-separated names.
std::string multiNames;
for (int i = 0; i < std::min(static_cast<int>(paDevices_.size()), kMaxChannels); ++i) {
if (paDeviceSelected_[i]) {
if (!multiNames.empty()) multiNames += ',';
multiNames += paDevices_[i].name;
}
}
cfg.setString("multi_device_names", multiNames);
cfg.save();
}
} // namespace baudmine