From b42d7fb69bde25c63c39b0c08847401b7060caba Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 28 Mar 2026 15:40:16 +0100 Subject: [PATCH] split off more stuff --- CMakeLists.txt | 2 + src/ui/Application.cpp | 1137 +++++---------------------------------- src/ui/Application.h | 115 +--- src/ui/ControlPanel.cpp | 396 ++++++++++++++ src/ui/ControlPanel.h | 53 ++ src/ui/DisplayPanel.cpp | 478 ++++++++++++++++ src/ui/DisplayPanel.h | 72 +++ src/ui/UIState.h | 32 ++ 8 files changed, 1196 insertions(+), 1089 deletions(-) create mode 100644 src/ui/ControlPanel.cpp create mode 100644 src/ui/ControlPanel.h create mode 100644 src/ui/DisplayPanel.cpp create mode 100644 src/ui/DisplayPanel.h create mode 100644 src/ui/UIState.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 12d3f98..5b9ad17 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 ) diff --git a/src/ui/Application.cpp b/src/ui/Application.cpp index 044d057..530aa3a 100644 --- a/src/ui/Application.cpp +++ b/src/ui/Application.cpp @@ -25,8 +25,6 @@ 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 = ''; } @@ -44,6 +42,8 @@ namespace baudmine { Application::Application() = default; +// ── UI scaling ────────────────────────────────────────────────────────────── + void Application::syncCanvasSize() { #ifdef __EMSCRIPTEN__ double cssW, cssH; @@ -104,6 +104,8 @@ void Application::requestUIScale(float scale) { pendingScale_ = scale; } +// ── Lifecycle ─────────────────────────────────────────────────────────────── + Application::~Application() { shutdown(); } @@ -114,16 +116,16 @@ bool Application::init(int argc, char** argv) { 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; + if (fmt == "f32") controlPanel_.fileFormatIdx = 0; + if (fmt == "i16") controlPanel_.fileFormatIdx = 1; + if (fmt == "u8") controlPanel_.fileFormatIdx = 2; + if (fmt == "wav") controlPanel_.fileFormatIdx = 3; } else if (arg == "--rate" && i + 1 < argc) { - fileSampleRate_ = std::stof(argv[++i]); + controlPanel_.fileSampleRate = std::stof(argv[++i]); } else if (arg == "--iq") { audio_.settings().isIQ = true; } else if (arg[0] != '-') { - filePath_ = arg; + controlPanel_.filePath = arg; } } @@ -161,11 +163,9 @@ bool Application::init(int argc, char** argv) { SDL_GL_MakeCurrent(window_, glContext_); SDL_GL_SetSwapInterval(1); - // ImGui init IMGUI_CHECKVERSION(); ImGui::CreateContext(); - ImGuiIO& io = ImGui::GetIO(); - io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; ImGui::StyleColorsDark(); ImGuiStyle& style = ImGui::GetStyle(); @@ -180,16 +180,11 @@ bool Application::init(int argc, char** argv) { ImGui_ImplOpenGL3_Init("#version 120"); #endif - // Enumerate audio devices audio_.enumerateDevices(); - - // Load saved config loadConfig(); - - // Sync canvas to physical pixels before first frame (WASM) syncCanvasSize(); - // Apply DPI-aware UI scaling + // DPI-aware UI scaling { float dpiScale = 1.0f; #ifdef __EMSCRIPTEN__ @@ -200,38 +195,59 @@ bool Application::init(int argc, char** argv) { if (SDL_GetDisplayDPI(0, &ddpi, nullptr, nullptr) == 0 && ddpi > 0) dpiScale = ddpi / 96.0f; #endif - float scale = (uiScale_ > 0.0f) ? uiScale_ : dpiScale; - applyUIScale(scale); + applyUIScale((uiScale_ > 0.0f) ? uiScale_ : dpiScale); } // Apply loaded settings auto& settings = audio_.settings(); - settings.fftSize = kFFTSizes[fftSizeIdx_]; - settings.overlap = overlapPct_ / 100.0f; - settings.window = static_cast(windowIdx_); - settings.sampleRate = fileSampleRate_; + settings.fftSize = ControlPanel::kFFTSizes[controlPanel_.fftSizeIdx]; + settings.overlap = controlPanel_.overlapPct / 100.0f; + settings.window = static_cast(controlPanel_.windowIdx); + settings.sampleRate = controlPanel_.fileSampleRate; settings.isIQ = false; - // Open source - if (!filePath_.empty()) { + if (!controlPanel_.filePath.empty()) { InputFormat fmt; - switch (fileFormatIdx_) { + switch (controlPanel_.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_); + openFile(controlPanel_.filePath, fmt, controlPanel_.fileSampleRate); } else { openDevice(); } updateAnalyzerSettings(); - running_ = true; return true; } +void Application::shutdown() { + audio_.closeAll(); + 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(); +} + +#ifdef __EMSCRIPTEN__ +static void emMainLoop(void* arg) { + static_cast(arg)->mainLoopStep(); +} +#endif + +void Application::run() { +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop_arg(emMainLoop, this, 0, true); +#else + while (running_) mainLoopStep(); +#endif +} + void Application::mainLoopStep() { syncCanvasSize(); @@ -245,7 +261,7 @@ void Application::mainLoopStep() { SDL_Event event; while (SDL_PollEvent(&event)) { ImGui_ImplSDL2_ProcessEvent(&event); - handleTouchEvent(event); + displayPanel_.handleTouch(event, ui_, window_); if (event.type == SDL_QUIT) running_ = false; if (event.type == SDL_KEYDOWN) { @@ -253,9 +269,9 @@ void Application::mainLoopStep() { #ifndef __EMSCRIPTEN__ if (key == SDLK_ESCAPE) running_ = false; #endif - if (key == SDLK_SPACE) paused_ = !paused_; + if (key == SDLK_SPACE) ui_.paused = !ui_.paused; if (key == SDLK_p) { - int pkCh = std::clamp(waterfallChannel_, 0, + int pkCh = std::clamp(ui_.waterfallChannel, 0, audio_.totalNumSpectra() - 1); cursors_.snapToPeak(audio_.getSpectrum(pkCh), settings.sampleRate, settings.isIQ, @@ -264,45 +280,13 @@ void Application::mainLoopStep() { } } - if (!paused_) + if (!ui_.paused) processAudio(); render(); } -#ifdef __EMSCRIPTEN__ -static void emMainLoop(void* arg) { - static_cast(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() { - audio_.closeAll(); - - 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(); -} +// ── Audio processing ──────────────────────────────────────────────────────── void Application::processAudio() { if (!audio_.hasSource()) return; @@ -317,41 +301,42 @@ void Application::processAudio() { const auto& mathChannels = audio_.mathChannels(); const auto& mathSpectra = audio_.mathSpectra(); - if (waterfallMultiCh_ && nSpec > 1) { - wfSpectraScratch_.clear(); - wfChInfoScratch_.clear(); + if (ui_.waterfallMultiCh && nSpec > 1) { + std::vector> wfSpectra; + std::vector wfInfo; for (int ch = 0; ch < nSpec; ++ch) { - const auto& c = channelColors_[ch % kMaxChannels]; - wfSpectraScratch_.push_back(audio_.getSpectrum(ch)); - wfChInfoScratch_.push_back({c.x, c.y, c.z, - channelEnabled_[ch % kMaxChannels]}); + const auto& c = ui_.channelColors[ch % kMaxChannels]; + wfSpectra.push_back(audio_.getSpectrum(ch)); + wfInfo.push_back({c.x, c.y, c.z, + ui_.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[0], c[1], c[2], true}); + wfSpectra.push_back(mathSpectra[mi]); + wfInfo.push_back({c[0], c[1], c[2], true}); } } - waterfall_.pushLineMulti(wfSpectraScratch_, wfChInfoScratch_, minDB_, maxDB_); + waterfall_.pushLineMulti(wfSpectra, wfInfo, ui_.minDB, ui_.maxDB); } else { - int wfCh = std::clamp(waterfallChannel_, 0, nSpec - 1); - waterfall_.pushLine(audio_.getSpectrum(wfCh), minDB_, maxDB_); + int wfCh = std::clamp(ui_.waterfallChannel, 0, nSpec - 1); + waterfall_.pushLine(audio_.getSpectrum(wfCh), ui_.minDB, ui_.maxDB); } - int curCh = std::clamp(waterfallChannel_, 0, nSpec - 1); + int curCh = std::clamp(ui_.waterfallChannel, 0, nSpec - 1); cursors_.update(audio_.getSpectrum(curCh), settings.sampleRate, settings.isIQ, settings.fftSize); measurements_.update(audio_.getSpectrum(curCh), settings.sampleRate, settings.isIQ, settings.fftSize); } - if (audio_.source()->isEOF() && !audio_.source()->isRealTime()) { - paused_ = true; - } + if (audio_.source()->isEOF() && !audio_.source()->isRealTime()) + ui_.paused = true; } +// ── Rendering ─────────────────────────────────────────────────────────────── + void Application::render() { if (SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED) { SDL_Delay(16); @@ -362,11 +347,10 @@ void Application::render() { ImGui_ImplSDL2_NewFrame(); ImGui::NewFrame(); - hoverPanel_ = HoverPanel::None; + displayPanel_.hoverPanel = DisplayPanel::HoverPanel::None; const auto& settings = audio_.settings(); - // Full-screen layout ImGuiViewport* viewport = ImGui::GetMainViewport(); ImGui::SetNextWindowPos(viewport->WorkPos); ImGui::SetNextWindowSize(viewport->WorkSize); @@ -376,7 +360,7 @@ void Application::render() { ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_MenuBar); - // Menu bar + // ── Menu bar ── if (ImGui::BeginMenuBar()) { if (ImGui::Button(showSidebar_ ? " << " : " >> ")) { showSidebar_ = !showSidebar_; @@ -388,37 +372,35 @@ void Application::render() { 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); + if (controlPanel_.filePath.size() < sizeof(filePathBuf)) + std::strncpy(filePathBuf, controlPanel_.filePath.c_str(), sizeof(filePathBuf) - 1); ImGui::SetNextItemWidth(200); if (ImGui::InputText("Path", filePathBuf, sizeof(filePathBuf))) - filePath_ = filePathBuf; + controlPanel_.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::Combo("Format", &controlPanel_.fileFormatIdx, formatNames, 4); ImGui::SetNextItemWidth(140); - ImGui::DragFloat("Sample Rate", &fileSampleRate_, 1000.0f, 1000.0f, 100e6f, "%.0f Hz"); - ImGui::Checkbox("Loop", &fileLoop_); + ImGui::DragFloat("Sample Rate", &controlPanel_.fileSampleRate, 1000.0f, 1000.0f, 100e6f, "%.0f Hz"); + ImGui::Checkbox("Loop", &controlPanel_.fileLoop); if (ImGui::MenuItem("Open File")) { InputFormat fmt; - switch (fileFormatIdx_) { + switch (controlPanel_.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_); + openFile(controlPanel_.filePath, fmt, controlPanel_.fileSampleRate); updateAnalyzerSettings(); } ImGui::Separator(); - // ── Audio device ── const auto& devices = audio_.devices(); if (!devices.empty()) { bool multiMode = audio_.multiDeviceMode(); @@ -466,10 +448,8 @@ void Application::render() { } } if (ImGui::MenuItem("Open Audio Device")) { - if (audio_.multiDeviceMode()) - openMultiDevice(); - else - openDevice(); + if (audio_.multiDeviceMode()) openMultiDevice(); + else openDevice(); updateAnalyzerSettings(); } @@ -481,7 +461,6 @@ void Application::render() { 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); @@ -515,7 +494,6 @@ void Application::render() { } ImGui::EndMenu(); } - ImGui::EndMenu(); } @@ -533,12 +511,10 @@ void Application::render() { #endif #ifdef __EMSCRIPTEN__ - if (ImGui::SmallButton(js_isFullscreen() ? "Exit Fullscreen" : "Fullscreen")) { + if (ImGui::SmallButton(js_isFullscreen() ? "Exit Fullscreen" : "Fullscreen")) js_toggleFullscreen(); - } #endif - // Right-aligned status in menu bar { float barW = ImGui::GetWindowWidth(); char statusBuf[128]; @@ -554,7 +530,7 @@ void Application::render() { ImGui::EndMenuBar(); } - // Layout + // ── Layout ── float totalW = ImGui::GetContentRegionAvail().x; float contentH = ImGui::GetContentRegionAvail().y; float controlW = showSidebar_ ? 270.0f * logicalScale_ : 0.0f; @@ -562,19 +538,27 @@ void Application::render() { if (showSidebar_) { ImGui::BeginChild("Controls", {controlW, contentH}, true); - renderControlPanel(); + controlPanel_.render(audio_, ui_, specDisplay_, cursors_, + measurements_, colorMap_, waterfall_); ImGui::EndChild(); + + if (controlPanel_.needsAnalyzerUpdate()) + updateAnalyzerSettings(); + if (controlPanel_.needsSave()) + saveConfig(); + ImGui::SameLine(); } - // Waterfall (top) + Spectrum (bottom) with draggable splitter + // ── Display area ── ImGui::BeginChild("Display", {contentW, contentH}, false); { constexpr float kSplitterH = 6.0f; - renderWaterfallPanel(); + displayPanel_.renderWaterfall(audio_, ui_, waterfall_, specDisplay_, + cursors_, measurements_, colorMap_); - // ── Draggable splitter bar ── + // Draggable splitter ImVec2 splPos = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton("##splitter", {contentW, kSplitterH}); bool hovered = ImGui::IsItemHovered(); @@ -584,12 +568,11 @@ void Application::render() { ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); if (active) { - float dy = ImGui::GetIO().MouseDelta.y; - spectrumFrac_ -= dy / contentH; - spectrumFrac_ = std::clamp(spectrumFrac_, 0.1f, 0.9f); - draggingSplit_ = true; - } else if (draggingSplit_) { - draggingSplit_ = false; + displayPanel_.spectrumFrac -= ImGui::GetIO().MouseDelta.y / contentH; + displayPanel_.spectrumFrac = std::clamp(displayPanel_.spectrumFrac, 0.1f, 0.9f); + displayPanel_.draggingSplit = true; + } else if (displayPanel_.draggingSplit) { + displayPanel_.draggingSplit = false; saveConfig(); } @@ -600,52 +583,8 @@ void Application::render() { 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); - - dlp->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; - 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) ── - { - 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(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); - } - - 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); - } - } + displayPanel_.renderSpectrum(audio_, ui_, specDisplay_, cursors_, measurements_); + displayPanel_.renderHoverOverlay(audio_, ui_, cursors_, specDisplay_); } ImGui::EndChild(); @@ -668,741 +607,11 @@ void Application::render() { SDL_GL_SwapWindow(window_); } -void Application::renderControlPanel() { - const auto& settings = audio_.settings(); - - // ── 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})) { - audio_.clearHistory(); - } - ImGui::SameLine(); - if (ImGui::Button("Peak", {btnW, 0})) { - int pkCh = std::clamp(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_]; - 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(WindowType::Count))) { - audio_.settings().window = static_cast(windowIdx_); - updateAnalyzerSettings(); - saveConfig(); - } - 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")) - updateAnalyzerSettings(); - } - - // Overlap - { - int hopSamples = static_cast(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; - 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 = audio_.totalNumSpectra(); - bool isMulti = 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})) { - waterfallMultiCh_ = !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", &channelEnabled_[ch]); - ImGui::SameLine(); - ImGui::ColorEdit3(defaultNames[ch], &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(ColorMapType::Count))) { - colorMap_.setType(static_cast(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 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(); - } - } - - // ── 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) 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() { - 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(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(audio_.getSpectrum(ch)); - const auto& c = channelColors_[ch % kMaxChannels]; - uint8_t r = static_cast(c.x * 255); - uint8_t g = static_cast(c.y * 255); - uint8_t b = static_cast(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(mathChannels.size()) && mathChannels[mi].enabled) { - allSpectraScratch_.push_back(mathSpectra[mi]); - const auto& c = mathChannels[mi].color; - uint8_t r = static_cast(c[0] * 255); - uint8_t g = static_cast(c[1] * 255); - uint8_t b = static_cast(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_, 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() { - 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(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(waterfall_.textureID()); - - int h = waterfall_.height(); - int screenRows = std::min(static_cast(availH), h); - int newestRow = (waterfall_.currentRow() + 1) % h; - - float rowToV = 1.0f / h; - - bool logMode = (freqScale_ == FreqScale::Logarithmic && !settings.isIQ); - - auto drawSpan = [&](int rowStart, int rowCount, float yStart, float spanH) { - float v0 = rowStart * rowToV; - float v1 = (rowStart + rowCount) * rowToV; - - if (!logMode) { - dl->AddImage(texID, - {pos.x, yStart}, - {pos.x + availW, yStart + spanH}, - {viewLo_, 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(availW)); - for (int s = 0; s < numStrips; ++s) { - float sL = static_cast(s) / numStrips; - float sR = static_cast(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}); - } - } - }; - - float pxPerRow = availH / static_cast(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(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); - } - - 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, 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, freqScale_, - viewLo_, 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((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(settings.fftSize * (1.0f - settings.overlap)); - if (hopSamples < 1) hopSamples = 1; - double secondsPerLine = static_cast(hopSamples) / settings.sampleRate; - hoverWfTimeOffset_ = static_cast(yFrac * screenRows * secondsPerLine); - - int curCh = std::clamp(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 = 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); - } - - 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; - } - - 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); - } - - 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 = viewLo_; - touch_.startHi = 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(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; } - 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) { - 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, freqScale_, - viewLo_, viewHi_); - float dB = specDisplay_.screenYToDB(my, posY, sizeY, minDB_, 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((freq - freqMin) / (freqMax - freqMin) * (bins - 1)); - bin = std::clamp(bin, 0, bins - 1); - - int curCh = std::clamp(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; - minDB_ += zoom; - maxDB_ -= zoom; - if (maxDB_ - minDB_ < 10.0f) { - float mid = (minDB_ + maxDB_) / 2.0f; - minDB_ = mid - 5.0f; - maxDB_ = mid + 5.0f; - } - } - 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); - } - - 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; - } - - if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Middle)) { - viewLo_ = 0.0f; - viewHi_ = 1.0f; - } - } - } else { - if (hoverPanel_ != HoverPanel::Waterfall) { - hoverPanel_ = HoverPanel::None; - cursors_.hover.active = false; - } - } -} - -// ── Source management (delegates to AudioEngine) ───────────────────────────── +// ── Source management ─────────────────────────────────────────────────────── void Application::openDevice() { audio_.openDevice(audio_.deviceIdx()); - fileSampleRate_ = static_cast(audio_.settings().sampleRate); + controlPanel_.fileSampleRate = static_cast(audio_.settings().sampleRate); } void Application::openMultiDevice() { @@ -1415,8 +624,8 @@ void Application::openMultiDevice() { } void Application::openFile(const std::string& path, InputFormat format, double sampleRate) { - audio_.openFile(path, format, sampleRate, fileLoop_); - fileSampleRate_ = static_cast(audio_.settings().sampleRate); + audio_.openFile(path, format, sampleRate, controlPanel_.fileLoop); + controlPanel_.fileSampleRate = static_cast(audio_.settings().sampleRate); } void Application::updateAnalyzerSettings() { @@ -1425,9 +634,9 @@ void Application::updateAnalyzerSettings() { bool oldIQ = settings.isIQ; int oldNCh = settings.numChannels; - settings.fftSize = kFFTSizes[fftSizeIdx_]; - settings.overlap = overlapPct_ / 100.0f; - settings.window = static_cast(windowIdx_); + settings.fftSize = ControlPanel::kFFTSizes[controlPanel_.fftSizeIdx]; + settings.overlap = controlPanel_.overlapPct / 100.0f; + settings.window = static_cast(controlPanel_.windowIdx); audio_.configure(settings); bool sizeChanged = settings.fftSize != oldFFTSize || @@ -1436,100 +645,43 @@ void Application::updateAnalyzerSettings() { if (sizeChanged) { audio_.drainSources(); - cursors_.cursorA.active = false; cursors_.cursorB.active = false; - int reinitH = std::max(1024, waterfall_.height()); - int binCount2 = std::max(1, audio_.spectrumSize()); - waterfall_.init(binCount2, reinitH); + int binCount = std::max(1, audio_.spectrumSize()); + waterfall_.init(binCount, reinitH); } } -// ── Math panel ─────────────────────────────────────────────────────────────── - -void Application::renderMathPanel() { - 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(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(MathOp::Count); ++o) { - auto op = static_cast(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); -} - // ── Config persistence ────────────────────────────────────────────────────── 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(freqScale_)); - freqScale_ = static_cast(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); + controlPanel_.fftSizeIdx = config_.getInt("fft_size_idx", controlPanel_.fftSizeIdx); + controlPanel_.overlapPct = config_.getFloat("overlap_pct", controlPanel_.overlapPct); + controlPanel_.windowIdx = config_.getInt("window_idx", controlPanel_.windowIdx); + controlPanel_.colorMapIdx = config_.getInt("colormap_idx", controlPanel_.colorMapIdx); + ui_.minDB = config_.getFloat("min_db", ui_.minDB); + ui_.maxDB = config_.getFloat("max_db", ui_.maxDB); + int fs = config_.getInt("freq_scale", static_cast(ui_.freqScale)); + ui_.freqScale = static_cast(fs); + vsync_ = config_.getBool("vsync", vsync_); + uiScale_ = config_.getFloat("ui_scale", uiScale_); + displayPanel_.spectrumFrac = config_.getFloat("spectrum_frac", displayPanel_.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(WindowType::Count) - 1); - colorMapIdx_ = std::clamp(colorMapIdx_, 0, static_cast(ColorMapType::Count) - 1); - spectrumFrac_ = std::clamp(spectrumFrac_, 0.1f, 0.9f); + controlPanel_.fftSizeIdx = std::clamp(controlPanel_.fftSizeIdx, 0, ControlPanel::kNumFFTSizes - 1); + controlPanel_.windowIdx = std::clamp(controlPanel_.windowIdx, 0, static_cast(WindowType::Count) - 1); + controlPanel_.colorMapIdx = std::clamp(controlPanel_.colorMapIdx, 0, static_cast(ColorMapType::Count) - 1); + displayPanel_.spectrumFrac = std::clamp(displayPanel_.spectrumFrac, 0.1f, 0.9f); - // Restore device selection. + // Restore device selection const auto& devices = audio_.devices(); audio_.setMultiDeviceMode(config_.getBool("multi_device", false)); std::string devName = config_.getString("device_name", ""); @@ -1541,7 +693,6 @@ void Application::loadConfig() { } } } - // Restore multi-device selections from comma-separated device names. audio_.clearDeviceSelections(); std::string multiNames = config_.getString("multi_device_names", ""); if (!multiNames.empty()) { @@ -1558,30 +709,28 @@ void Application::loadConfig() { } } - // Apply auto& settings = audio_.settings(); - settings.fftSize = kFFTSizes[fftSizeIdx_]; - settings.overlap = overlapPct_ / 100.0f; - settings.window = static_cast(windowIdx_); - colorMap_.setType(static_cast(colorMapIdx_)); + settings.fftSize = ControlPanel::kFFTSizes[controlPanel_.fftSizeIdx]; + settings.overlap = controlPanel_.overlapPct / 100.0f; + settings.window = static_cast(controlPanel_.windowIdx); + colorMap_.setType(static_cast(controlPanel_.colorMapIdx)); SDL_GL_SetSwapInterval(vsync_ ? 1 : 0); } void Application::saveConfig() const { - const auto& settings = audio_.settings(); - const auto& devices = audio_.devices(); + const auto& devices = audio_.devices(); 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(freqScale_)); + cfg.setInt("fft_size_idx", controlPanel_.fftSizeIdx); + cfg.setFloat("overlap_pct", controlPanel_.overlapPct); + cfg.setInt("window_idx", controlPanel_.windowIdx); + cfg.setInt("colormap_idx", controlPanel_.colorMapIdx); + cfg.setFloat("min_db", ui_.minDB); + cfg.setFloat("max_db", ui_.maxDB); + cfg.setInt("freq_scale", static_cast(ui_.freqScale)); cfg.setBool("vsync", vsync_); cfg.setFloat("ui_scale", uiScale_); - cfg.setFloat("spectrum_frac", spectrumFrac_); + cfg.setFloat("spectrum_frac", displayPanel_.spectrumFrac); cfg.setBool("show_sidebar", showSidebar_); cfg.setBool("peak_hold", specDisplay_.peakHoldEnable); cfg.setFloat("peak_hold_decay", specDisplay_.peakHoldDecay); diff --git a/src/ui/Application.h b/src/ui/Application.h index 813debd..b25f683 100644 --- a/src/ui/Application.h +++ b/src/ui/Application.h @@ -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 #include -#include 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) - AudioEngine audio_; + // 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; - 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) + // UI scaling + bool vsync_ = true; + 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(WindowType::BlackmanHarris); - - // Color map - int colorMapIdx_ = static_cast(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; + 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> wfSpectraScratch_; - std::vector wfChInfoScratch_; - std::vector> allSpectraScratch_; - std::vector stylesScratch_; + // Config persistence + Config config_; }; } // namespace baudmine diff --git a/src/ui/ControlPanel.cpp b/src/ui/ControlPanel.cpp new file mode 100644 index 0000000..d57442e --- /dev/null +++ b/src/ui/ControlPanel.cpp @@ -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 +#include +#include +#include + +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(WindowType::Count))) { + audio.settings().window = static_cast(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(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(ColorMapType::Count))) { + colorMap.setType(static_cast(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(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(MathOp::Count); ++o) { + auto op = static_cast(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 diff --git a/src/ui/ControlPanel.h b/src/ui/ControlPanel.h new file mode 100644 index 0000000..4d691f9 --- /dev/null +++ b/src/ui/ControlPanel.h @@ -0,0 +1,53 @@ +#pragma once + +#include "core/Types.h" +#include "ui/UIState.h" + +#include + +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(WindowType::BlackmanHarris); + int colorMapIdx = static_cast(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 diff --git a/src/ui/DisplayPanel.cpp b/src/ui/DisplayPanel.cpp new file mode 100644 index 0000000..e040731 --- /dev/null +++ b/src/ui/DisplayPanel.cpp @@ -0,0 +1,478 @@ +#include "ui/DisplayPanel.h" +#include "audio/AudioEngine.h" + +#include +#include +#include +#include + +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(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(c.x * 255); + uint8_t g = static_cast(c.y * 255); + uint8_t b = static_cast(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(mathChannels.size()) && mathChannels[mi].enabled) { + allSpectraScratch_.push_back(mathSpectra[mi]); + const auto& c = mathChannels[mi].color; + uint8_t r = static_cast(c[0] * 255); + uint8_t g = static_cast(c[1] * 255); + uint8_t b = static_cast(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(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(waterfall.textureID()); + + int h = waterfall.height(); + int screenRows = std::min(static_cast(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(availW)); + for (int s = 0; s < numStrips; ++s) { + float sL = static_cast(s) / numStrips; + float sR = static_cast(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(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(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((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(settings.fftSize * (1.0f - settings.overlap)); + if (hopSamples < 1) hopSamples = 1; + double secondsPerLine = static_cast(hopSamples) / settings.sampleRate; + hoverWfTimeOff = static_cast(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(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((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(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 diff --git a/src/ui/DisplayPanel.h b/src/ui/DisplayPanel.h new file mode 100644 index 0000000..0b6943a --- /dev/null +++ b/src/ui/DisplayPanel.h @@ -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 +#include + +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> wfSpectraScratch_; + std::vector wfChInfoScratch_; + std::vector> allSpectraScratch_; + std::vector stylesScratch_; +}; + +} // namespace baudmine diff --git a/src/ui/UIState.h b/src/ui/UIState.h new file mode 100644 index 0000000..2f14eea --- /dev/null +++ b/src/ui/UIState.h @@ -0,0 +1,32 @@ +#pragma once + +#include "core/Types.h" +#include + +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