diff --git a/CMakeLists.txt b/CMakeLists.txt index 2145d81..9f3761e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,7 @@ target_link_libraries(imgui PUBLIC PkgConfig::SDL2 OpenGL::GL) set(SOURCES src/main.cpp + src/core/Config.cpp src/dsp/WindowFunctions.cpp src/dsp/FFTProcessor.cpp src/dsp/SpectrumAnalyzer.cpp diff --git a/src/core/Config.cpp b/src/core/Config.cpp new file mode 100644 index 0000000..537498b --- /dev/null +++ b/src/core/Config.cpp @@ -0,0 +1,105 @@ +#include "core/Config.h" +#include +#include +#include +#include + +namespace baudline { + +std::string Config::defaultPath() { + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + std::string base; + if (xdg && xdg[0]) { + base = xdg; + } else { + const char* home = std::getenv("HOME"); + base = home ? std::string(home) + "/.config" : "."; + } + return base + "/baudline/settings.ini"; +} + +std::string Config::resolvedPath(const std::string& path) const { + return path.empty() ? defaultPath() : path; +} + +bool Config::load(const std::string& path) { + std::ifstream f(resolvedPath(path)); + if (!f.is_open()) return false; + + data_.clear(); + std::string line; + while (std::getline(f, line)) { + if (line.empty() || line[0] == '#') continue; + auto eq = line.find('='); + if (eq == std::string::npos) continue; + std::string key = line.substr(0, eq); + std::string val = line.substr(eq + 1); + // Trim whitespace. + while (!key.empty() && key.back() == ' ') key.pop_back(); + while (!val.empty() && val.front() == ' ') val.erase(val.begin()); + data_[key] = val; + } + return true; +} + +static void ensureDir(const std::string& path) { + // Create parent directories. + auto lastSlash = path.rfind('/'); + if (lastSlash == std::string::npos) return; + std::string dir = path.substr(0, lastSlash); + // Simple recursive mkdir. + for (size_t i = 1; i < dir.size(); ++i) { + if (dir[i] == '/') { + dir[i] = '\0'; + mkdir(dir.c_str(), 0755); + dir[i] = '/'; + } + } + mkdir(dir.c_str(), 0755); +} + +bool Config::save(const std::string& path) const { + std::string p = resolvedPath(path); + ensureDir(p); + std::ofstream f(p); + if (!f.is_open()) return false; + + f << "# Baudline settings\n"; + for (const auto& [k, v] : data_) + f << k << " = " << v << "\n"; + return true; +} + +void Config::setString(const std::string& key, const std::string& value) { data_[key] = value; } +void Config::setInt(const std::string& key, int value) { data_[key] = std::to_string(value); } +void Config::setFloat(const std::string& key, float value) { + std::ostringstream ss; + ss << value; + data_[key] = ss.str(); +} +void Config::setBool(const std::string& key, bool value) { data_[key] = value ? "1" : "0"; } + +std::string Config::getString(const std::string& key, const std::string& def) const { + auto it = data_.find(key); + return it != data_.end() ? it->second : def; +} + +int Config::getInt(const std::string& key, int def) const { + auto it = data_.find(key); + if (it == data_.end()) return def; + try { return std::stoi(it->second); } catch (...) { return def; } +} + +float Config::getFloat(const std::string& key, float def) const { + auto it = data_.find(key); + if (it == data_.end()) return def; + try { return std::stof(it->second); } catch (...) { return def; } +} + +bool Config::getBool(const std::string& key, bool def) const { + auto it = data_.find(key); + if (it == data_.end()) return def; + return it->second == "1" || it->second == "true"; +} + +} // namespace baudline diff --git a/src/core/Config.h b/src/core/Config.h new file mode 100644 index 0000000..10fb47a --- /dev/null +++ b/src/core/Config.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace baudline { + +// Simple INI-style config: key = value, one per line. Lines starting with # are +// comments. No sections. Stored at ~/.config/baudline/settings.ini. +class Config { +public: + static std::string defaultPath(); + + bool load(const std::string& path = ""); + bool save(const std::string& path = "") const; + + void setString(const std::string& key, const std::string& value); + void setInt(const std::string& key, int value); + void setFloat(const std::string& key, float value); + void setBool(const std::string& key, bool value); + + std::string getString(const std::string& key, const std::string& def = "") const; + int getInt(const std::string& key, int def = 0) const; + float getFloat(const std::string& key, float def = 0.0f) const; + bool getBool(const std::string& key, bool def = false) const; + +private: + std::unordered_map data_; + std::string resolvedPath(const std::string& path) const; +}; + +} // namespace baudline diff --git a/src/ui/Application.cpp b/src/ui/Application.cpp index d681048..49aaaef 100644 --- a/src/ui/Application.cpp +++ b/src/ui/Application.cpp @@ -80,7 +80,10 @@ bool Application::init(int argc, char** argv) { // Enumerate audio devices paDevices_ = PortAudioSource::listInputDevices(); - // Default settings + // Load saved config (overwrites defaults for FFT size, overlap, window, etc.) + loadConfig(); + + // Apply loaded settings settings_.fftSize = kFFTSizes[fftSizeIdx_]; settings_.overlap = overlapPct_ / 100.0f; settings_.window = static_cast(windowIdx_); @@ -243,22 +246,94 @@ void Application::render() { // 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")) { - if (ImGui::MenuItem("Open WAV...")) { - // TODO: file dialog integration + // ── 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()) { + ImGui::Text("Audio Device"); + std::vector devNames; + for (auto& d : paDevices_) devNames.push_back(d.name.c_str()); + ImGui::SetNextItemWidth(250); + if (ImGui::Combo("##device", &paDeviceIdx_, devNames.data(), + static_cast(devNames.size()))) { + openPortAudio(); + updateAnalyzerSettings(); + saveConfig(); + } + } + if (ImGui::MenuItem("Open PortAudio")) { + 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(); + + // Frequency scale + int fs = static_cast(freqScale_); + const char* fsNames[] = {"Linear", "Logarithmic"}; + ImGui::SetNextItemWidth(120); + if (ImGui::Combo("Freq Scale", &fs, fsNames, 2)) { + freqScale_ = static_cast(fs); + saveConfig(); + } + ImGui::Separator(); if (ImGui::MenuItem("VSync", nullptr, &vsync_)) { SDL_GL_SetSwapInterval(vsync_ ? 1 : 0); + saveConfig(); } + ImGui::EndMenu(); } + if (ImGui::BeginMenu("Debug")) { ImGui::MenuItem("Metrics/Debugger", nullptr, &showMetricsWindow_); ImGui::MenuItem("Debug Log", nullptr, &showDebugLog_); @@ -269,20 +344,36 @@ void Application::render() { 1000.0f / ImGui::GetIO().Framerate); ImGui::EndMenu(); } + + // 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: controls on left (250px), spectrum+waterfall on right - float controlW = 260.0f; - float contentW = ImGui::GetContentRegionAvail().x - controlW - 8; + // Layout + float totalW = ImGui::GetContentRegionAvail().x; float contentH = ImGui::GetContentRegionAvail().y; + float controlW = showSidebar_ ? 270.0f : 0.0f; + float contentW = totalW - (showSidebar_ ? controlW + 8 : 0); - // Control panel - ImGui::BeginChild("Controls", {controlW, contentH}, true); - renderControlPanel(); - ImGui::EndChild(); - - ImGui::SameLine(); + // Control panel (sidebar) + if (showSidebar_) { + ImGui::BeginChild("Controls", {controlW, contentH}, true); + renderControlPanel(); + ImGui::EndChild(); + ImGui::SameLine(); + } // Spectrum + Waterfall with draggable splitter ImGui::BeginChild("Display", {contentW, contentH}, false); @@ -306,6 +397,10 @@ void Application::render() { float dy = ImGui::GetIO().MouseDelta.y; spectrumFrac_ += dy / contentH; spectrumFrac_ = std::clamp(spectrumFrac_, 0.1f, 0.9f); + draggingSplit_ = true; + } else if (draggingSplit_) { + draggingSplit_ = false; + saveConfig(); } // Draw splitter line @@ -369,239 +464,197 @@ void Application::render() { } void Application::renderControlPanel() { - ImGui::TextColored({0.4f, 0.8f, 1.0f, 1.0f}, "BAUDLINE"); - ImGui::Separator(); - - // Input source - ImGui::Text("Input Source"); - if (ImGui::Button("PortAudio (Mic)")) { - openPortAudio(); - updateAnalyzerSettings(); - } - - ImGui::Separator(); - ImGui::Text("File Input"); - - // Show file path input - static char filePathBuf[512] = ""; - if (filePath_.size() < sizeof(filePathBuf)) - std::strncpy(filePathBuf, filePath_.c_str(), sizeof(filePathBuf) - 1); - if (ImGui::InputText("Path", filePathBuf, sizeof(filePathBuf))) - filePath_ = filePathBuf; - - const char* formatNames[] = {"Float32 I/Q", "Int16 I/Q", "Uint8 I/Q", "WAV"}; - ImGui::Combo("Format", &fileFormatIdx_, formatNames, 4); - ImGui::DragFloat("Sample Rate", &fileSampleRate_, 1000.0f, 1000.0f, 100e6f, "%.0f Hz"); - ImGui::Checkbox("Loop", &fileLoop_); - - if (ImGui::Button("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(); - } - - // PortAudio device list - if (!paDevices_.empty()) { - ImGui::Separator(); - ImGui::Text("Audio Device"); - std::vector devNames; - for (auto& d : paDevices_) devNames.push_back(d.name.c_str()); - if (ImGui::Combo("Device", &paDeviceIdx_, devNames.data(), - static_cast(devNames.size()))) { - openPortAudio(); - updateAnalyzerSettings(); - } - } - - ImGui::Separator(); - ImGui::Text("FFT Settings"); - - // FFT size - { - const char* sizeNames[] = {"256", "512", "1024", "2048", "4096", - "8192", "16384", "32768", "65536"}; - if (ImGui::Combo("FFT Size", &fftSizeIdx_, sizeNames, kNumFFTSizes)) { - settings_.fftSize = kFFTSizes[fftSizeIdx_]; - updateAnalyzerSettings(); - } - } - - // Overlap — inverted x⁴ curve: sensitive at the high end (90%+). - { - int hopSamples = static_cast(settings_.fftSize * (1.0f - settings_.overlap)); - if (hopSamples < 1) hopSamples = 1; - int overlapSamples = settings_.fftSize - hopSamples; - - float sliderVal = 1.0f - std::pow(1.0f - overlapPct_ / 99.0f, 0.25f); - if (ImGui::SliderFloat("Overlap", &sliderVal, 0.0f, 1.0f, "")) { - float inv = 1.0f - sliderVal; - float inv2 = inv * inv; - overlapPct_ = 99.0f * (1.0f - inv2 * inv2); - settings_.overlap = overlapPct_ / 100.0f; - updateAnalyzerSettings(); - } - - // Draw overlay text centered on the slider frame (not the label). - char overlayText[64]; - std::snprintf(overlayText, sizeof(overlayText), "%.1f%% (%d samples)", overlapPct_, overlapSamples); - ImVec2 textSize = ImGui::CalcTextSize(overlayText); - // The slider frame width = total widget width minus label. - // ImGui::CalcItemWidth() gives the frame width. - ImVec2 sliderMin = ImGui::GetItemRectMin(); - float frameW = ImGui::CalcItemWidth(); - float frameH = ImGui::GetItemRectMax().y - sliderMin.y; - float tx = sliderMin.x + (frameW - textSize.x) * 0.5f; - float ty = sliderMin.y + (frameH - textSize.y) * 0.5f; - ImGui::GetForegroundDrawList()->AddText({tx, ty}, IM_COL32(255, 255, 255, 220), overlayText); - } - - // Window function - { - const char* winNames[] = {"Rectangular", "Hann", "Hamming", "Blackman", - "Blackman-Harris", "Kaiser", "Flat Top"}; - if (ImGui::Combo("Window", &windowIdx_, winNames, - static_cast(WindowType::Count))) { - settings_.window = static_cast(windowIdx_); - if (settings_.window == WindowType::Kaiser) { - // Show Kaiser beta slider - } - updateAnalyzerSettings(); - } - } - - if (settings_.window == WindowType::Kaiser) { - if (ImGui::SliderFloat("Kaiser Beta", &settings_.kaiserBeta, 0.0f, 20.0f)) { - updateAnalyzerSettings(); - } - } - - ImGui::Separator(); - ImGui::Text("Display"); - - // Color map - { - const char* cmNames[] = {"Magma", "Viridis", "Inferno", "Plasma", "Grayscale"}; - if (ImGui::Combo("Color Map", &colorMapIdx_, cmNames, - static_cast(ColorMapType::Count))) { - colorMap_.setType(static_cast(colorMapIdx_)); - waterfall_.setColorMap(colorMap_); - } - } - - // Frequency scale - { - int fs = static_cast(freqScale_); - const char* fsNames[] = {"Linear", "Logarithmic"}; - if (ImGui::Combo("Freq Scale", &fs, fsNames, 2)) - freqScale_ = static_cast(fs); - } - - // Zoom info & reset - if (viewLo_ > 0.0f || viewHi_ < 1.0f) { - float zoomPct = 1.0f / (viewHi_ - viewLo_); - ImGui::Text("Zoom: %.1fx", zoomPct); - ImGui::SameLine(); - if (ImGui::SmallButton("Reset")) { - viewLo_ = 0.0f; - viewHi_ = 1.0f; - } - } - ImGui::TextDisabled("Scroll: freq zoom | MMB drag: pan"); - ImGui::TextDisabled("Ctrl+Scroll: dB zoom | MMB dbl: reset"); - - // dB range - ImGui::DragFloatRange2("dB Range", &minDB_, &maxDB_, 1.0f, -200.0f, 20.0f, - "Min: %.0f", "Max: %.0f"); - - // Peak hold - ImGui::Checkbox("Peak Hold", &specDisplay_.peakHoldEnable); - if (specDisplay_.peakHoldEnable) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80); - ImGui::SliderFloat("Decay", &specDisplay_.peakHoldDecay, 0.0f, 120.0f, "%.0f dB/s"); - ImGui::SameLine(); - if (ImGui::SmallButton("Clear##peakhold")) - specDisplay_.clearPeakHold(); - } - - // Channel colors (only shown for multi-channel) - int nCh = analyzer_.numSpectra(); - if (nCh > 1) { - ImGui::Separator(); - ImGui::Text("Channels (%d)", nCh); - - 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); - ImGui::PopID(); - } - - // Waterfall mode - ImGui::Checkbox("Multi-Ch Waterfall", &waterfallMultiCh_); - if (!waterfallMultiCh_) { - if (ImGui::SliderInt("Waterfall Ch", &waterfallChannel_, 0, nCh - 1)) - waterfallChannel_ = std::clamp(waterfallChannel_, 0, nCh - 1); - } - } - - // Math channels section (always shown). - ImGui::Separator(); - renderMathPanel(); - - ImGui::Separator(); - - // Playback controls - if (ImGui::Button(paused_ ? "Resume [Space]" : "Pause [Space]")) + // ── 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")) { + if (ImGui::Button("Clear", {btnW, 0})) analyzer_.clearHistory(); - } - - ImGui::Separator(); - - // Cursors - cursors_.drawPanel(); - - ImGui::Separator(); - if (ImGui::Button("Snap to Peak [P]")) { + ImGui::SameLine(); + if (ImGui::Button("Peak", {btnW, 0})) { int pkCh = std::clamp(waterfallChannel_, 0, analyzer_.numSpectra() - 1); cursors_.snapToPeak(analyzer_.channelSpectrum(pkCh), settings_.sampleRate, settings_.isIQ, settings_.fftSize); } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Snap cursor A to peak"); - // Status + // ── FFT ── + ImGui::Spacing(); + if (ImGui::CollapsingHeader("FFT", ImGuiTreeNodeFlags_DefaultOpen)) { + const char* sizeNames[] = {"256", "512", "1024", "2048", "4096", + "8192", "16384", "32768", "65536"}; + ImGui::SetNextItemWidth(-1); + if (ImGui::Combo("##fftsize", &fftSizeIdx_, sizeNames, kNumFFTSizes)) { + settings_.fftSize = kFFTSizes[fftSizeIdx_]; + updateAnalyzerSettings(); + saveConfig(); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("FFT Size"); + + const char* winNames[] = {"Rectangular", "Hann", "Hamming", "Blackman", + "Blackman-Harris", "Kaiser", "Flat Top"}; + ImGui::SetNextItemWidth(-1); + if (ImGui::Combo("##window", &windowIdx_, winNames, + static_cast(WindowType::Count))) { + 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", &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); + 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::GetForegroundDrawList()->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 (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(); + } + + if (viewLo_ > 0.0f || viewHi_ < 1.0f) { + float zoomPct = 1.0f / (viewHi_ - viewLo_); + ImGui::Text("Zoom: %.1fx", zoomPct); + ImGui::SameLine(); + if (ImGui::SmallButton("Reset##zoom")) { + viewLo_ = 0.0f; + viewHi_ = 1.0f; + } + } + } + + // ── Channels ── + ImGui::Spacing(); + { + int nCh = analyzer_.numSpectra(); + bool isMulti = waterfallMultiCh_ && nCh > 1; + + // Header with inline Single/Multi toggle + bool headerOpen = ImGui::CollapsingHeader("##channels_hdr", + ImGuiTreeNodeFlags_DefaultOpen | + ImGuiTreeNodeFlags_AllowOverlap); + ImGui::SameLine(); + ImGui::Text("Channels"); + if (nCh > 1) { + ImGui::SameLine(); + float btnW = 60.0f; + ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - btnW); + if (ImGui::Button(isMulti ? " Multi " : "Single ", {btnW, 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); + 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(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(); + if (ImGui::CollapsingHeader("Math")) { + renderMathPanel(); + } + + // ── Cursors ── + ImGui::Spacing(); + if (ImGui::CollapsingHeader("Cursors", ImGuiTreeNodeFlags_DefaultOpen)) { + cursors_.drawPanel(); + } + + // ── Status (bottom) ── ImGui::Separator(); - ImGui::Text("FFT: %d pt, %.1f Hz/bin", - settings_.fftSize, - settings_.sampleRate / settings_.fftSize); - ImGui::Text("Sample Rate: %.0f Hz", settings_.sampleRate); - ImGui::Text("Mode: %s", settings_.isIQ ? "I/Q (Complex)" - : (settings_.numChannels > 1 ? "Multi-channel Real" : "Real")); + ImGui::TextDisabled("Mode: %s", settings_.isIQ ? "I/Q" + : (settings_.numChannels > 1 ? "Multi-ch" : "Real")); int pkCh2 = std::clamp(waterfallChannel_, 0, analyzer_.numSpectra() - 1); auto [peakBin, peakDB] = analyzer_.findPeak(pkCh2); double peakFreq = analyzer_.binToFreq(peakBin); if (std::abs(peakFreq) >= 1e6) - ImGui::Text("Peak: %.6f MHz, %.1f dB", peakFreq / 1e6, peakDB); + ImGui::TextDisabled("Peak: %.6f MHz, %.1f dB", peakFreq / 1e6, peakDB); else if (std::abs(peakFreq) >= 1e3) - ImGui::Text("Peak: %.3f kHz, %.1f dB", peakFreq / 1e3, peakDB); + ImGui::TextDisabled("Peak: %.3f kHz, %.1f dB", peakFreq / 1e3, peakDB); else - ImGui::Text("Peak: %.1f Hz, %.1f dB", peakFreq, peakDB); + ImGui::TextDisabled("Peak: %.1f Hz, %.1f dB", peakFreq, peakDB); } void Application::renderSpectrumPanel() { @@ -1129,9 +1182,6 @@ void Application::computeMathChannels() { } void Application::renderMathPanel() { - ImGui::Text("Channel Math"); - ImGui::Separator(); - int nPhys = analyzer_.numSpectra(); // Build source channel name list. @@ -1197,4 +1247,66 @@ void Application::renderMathPanel() { } } +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_); + 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); + + // 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); + + // Find device by saved name. + std::string devName = config_.getString("device_name", ""); + if (!devName.empty()) { + for (int i = 0; i < static_cast(paDevices_.size()); ++i) { + if (paDevices_[i].name == devName) { + paDeviceIdx_ = i; + break; + } + } + } + + // Apply + settings_.fftSize = kFFTSizes[fftSizeIdx_]; + settings_.overlap = overlapPct_ / 100.0f; + settings_.window = static_cast(windowIdx_); + colorMap_.setType(static_cast(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(freqScale_)); + cfg.setBool("vsync", vsync_); + cfg.setFloat("spectrum_frac", spectrumFrac_); + cfg.setBool("show_sidebar", showSidebar_); + cfg.setBool("peak_hold", specDisplay_.peakHoldEnable); + cfg.setFloat("peak_hold_decay", specDisplay_.peakHoldDecay); + + if (paDeviceIdx_ >= 0 && paDeviceIdx_ < static_cast(paDevices_.size())) + cfg.setString("device_name", paDevices_[paDeviceIdx_].name); + + cfg.save(); +} + } // namespace baudline diff --git a/src/ui/Application.h b/src/ui/Application.h index 2c3f81f..0ffc4cc 100644 --- a/src/ui/Application.h +++ b/src/ui/Application.h @@ -1,6 +1,7 @@ #pragma once #include "core/Types.h" +#include "core/Config.h" #include "dsp/SpectrumAnalyzer.h" #include "audio/AudioSource.h" #include "audio/PortAudioSource.h" @@ -89,6 +90,9 @@ private: void computeMathChannels(); void renderMathPanel(); + void loadConfig(); + void saveConfig() const; + // SDL / GL / ImGui SDL_Window* window_ = nullptr; SDL_GLContext glContext_ = nullptr; @@ -162,7 +166,7 @@ private: // Frequency zoom/pan (normalized 0–1 over full bandwidth) float viewLo_ = 0.0f; // left edge - float viewHi_ = 1.0f; // right 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; @@ -172,6 +176,12 @@ private: float specPosX_ = 0, specPosY_ = 0, specSizeX_ = 0, specSizeY_ = 0; float wfPosX_ = 0, wfPosY_ = 0, wfSizeX_ = 0, wfSizeY_ = 0; + // Config persistence + Config config_; + + // UI visibility + bool showSidebar_ = true; + // ImGui debug windows bool showDemoWindow_ = false; bool showMetricsWindow_ = false; diff --git a/src/ui/Cursors.cpp b/src/ui/Cursors.cpp index 36b72a6..08e8b4e 100644 --- a/src/ui/Cursors.cpp +++ b/src/ui/Cursors.cpp @@ -13,16 +13,44 @@ static double binToFreqHelper(int bin, double sampleRate, bool isIQ, int fftSize } } +void Cursors::pushAvg(AvgState& st, float dB, int bin) const { + // Reset if cursor moved to a different bin or averaging was reduced. + if (bin != st.lastBin) { + st.samples.clear(); + st.sum = 0.0; + st.lastBin = bin; + } + st.samples.push_back(dB); + st.sum += dB; + int maxN = std::max(1, avgCount); + while (static_cast(st.samples.size()) > maxN) { + st.sum -= st.samples.front(); + st.samples.pop_front(); + } +} + +float Cursors::avgDBA() const { + return avgA_.samples.empty() ? cursorA.dB + : static_cast(avgA_.sum / avgA_.samples.size()); +} + +float Cursors::avgDBB() const { + return avgB_.samples.empty() ? cursorB.dB + : static_cast(avgB_.sum / avgB_.samples.size()); +} + void Cursors::update(const std::vector& spectrumDB, double sampleRate, bool isIQ, int fftSize) { // Update dB values at cursor bin positions if (cursorA.active && cursorA.bin >= 0 && cursorA.bin < static_cast(spectrumDB.size())) { cursorA.dB = spectrumDB[cursorA.bin]; + pushAvg(avgA_, cursorA.dB, cursorA.bin); } if (cursorB.active && cursorB.bin >= 0 && cursorB.bin < static_cast(spectrumDB.size())) { cursorB.dB = spectrumDB[cursorB.bin]; + pushAvg(avgB_, cursorB.dB, cursorB.bin); } } @@ -33,12 +61,12 @@ void Cursors::draw(const SpectrumDisplay& specDisplay, float viewLo, float viewHi) const { ImDrawList* dl = ImGui::GetWindowDrawList(); - auto drawCursor = [&](const CursorInfo& c, ImU32 color, const char* label) { + auto drawCursor = [&](const CursorInfo& c, float dispDB, ImU32 color, const char* label) { if (!c.active) return; float x = specDisplay.freqToScreenX(c.freq, posX, sizeX, sampleRate, isIQ, freqScale, viewLo, viewHi); - float dbNorm = (c.dB - minDB) / (maxDB - minDB); + float dbNorm = (dispDB - minDB) / (maxDB - minDB); dbNorm = std::clamp(dbNorm, 0.0f, 1.0f); float y = posY + sizeY * (1.0f - dbNorm); @@ -53,13 +81,13 @@ void Cursors::draw(const SpectrumDisplay& specDisplay, char buf[128]; if (std::abs(c.freq) >= 1e6) std::snprintf(buf, sizeof(buf), "%s: %.6f MHz %.1f dB", - label, c.freq / 1e6, c.dB); + label, c.freq / 1e6, dispDB); else if (std::abs(c.freq) >= 1e3) std::snprintf(buf, sizeof(buf), "%s: %.3f kHz %.1f dB", - label, c.freq / 1e3, c.dB); + label, c.freq / 1e3, dispDB); else std::snprintf(buf, sizeof(buf), "%s: %.1f Hz %.1f dB", - label, c.freq, c.dB); + label, c.freq, dispDB); ImVec2 textSize = ImGui::CalcTextSize(buf); float tx = std::min(x + 8, posX + sizeX - textSize.x - 4); @@ -69,76 +97,86 @@ void Cursors::draw(const SpectrumDisplay& specDisplay, dl->AddText({tx, ty}, color, buf); }; - drawCursor(cursorA, IM_COL32(255, 255, 0, 220), "A"); - drawCursor(cursorB, IM_COL32(0, 200, 255, 220), "B"); + float aDB = avgDBA(), bDB = avgDBB(); + drawCursor(cursorA, aDB, IM_COL32(255, 255, 0, 220), "A"); + drawCursor(cursorB, bDB, IM_COL32(0, 200, 255, 220), "B"); - // Delta display + // Delta display (two lines, column-aligned on '=') if (showDelta && cursorA.active && cursorB.active) { double dFreq = cursorB.freq - cursorA.freq; - float dDB = cursorB.dB - cursorA.dB; - char buf[128]; + float dDB = bDB - aDB; + char val1[48], val2[48]; if (std::abs(dFreq) >= 1e6) - std::snprintf(buf, sizeof(buf), "dF=%.6f MHz dA=%.1f dB", - dFreq / 1e6, dDB); + std::snprintf(val1, sizeof(val1), "%.6f MHz", dFreq / 1e6); else if (std::abs(dFreq) >= 1e3) - std::snprintf(buf, sizeof(buf), "dF=%.3f kHz dA=%.1f dB", - dFreq / 1e3, dDB); + std::snprintf(val1, sizeof(val1), "%.3f kHz", dFreq / 1e3); else - std::snprintf(buf, sizeof(buf), "dF=%.1f Hz dA=%.1f dB", - dFreq, dDB); + std::snprintf(val1, sizeof(val1), "%.1f Hz", dFreq); + std::snprintf(val2, sizeof(val2), "%.1f dB", dDB); - ImVec2 textSize = ImGui::CalcTextSize(buf); - float tx = posX + sizeX - textSize.x - 8; + ImVec2 labelSz = ImGui::CalcTextSize("dF = "); + ImVec2 v1Sz = ImGui::CalcTextSize(val1); + ImVec2 v2Sz = ImGui::CalcTextSize(val2); + float valW = std::max(v1Sz.x, v2Sz.x); + float lineH = labelSz.y; + float totalW = labelSz.x + valW; + float tx = posX + sizeX - totalW - 8; float ty = posY + 4; - dl->AddRectFilled({tx - 4, ty - 2}, {tx + textSize.x + 4, ty + textSize.y + 2}, - IM_COL32(0, 0, 0, 200)); - dl->AddText({tx, ty}, IM_COL32(255, 200, 100, 255), buf); + ImU32 col = IM_COL32(255, 200, 100, 255); + float eqX = tx + labelSz.x; // values start here (right of '= ') + + dl->AddText({tx, ty}, col, "dF ="); + dl->AddText({eqX + valW - v1Sz.x, ty}, col, val1); + dl->AddText({tx, ty + lineH + 2}, col, "dA ="); + dl->AddText({eqX + valW - v2Sz.x, ty + lineH + 2}, col, val2); } // (Hover cursor line is drawn cross-panel by Application.) } -void Cursors::drawPanel() const { - ImGui::Text("Cursors:"); - ImGui::Separator(); - - auto showCursor = [](const char* label, const CursorInfo& c) { +void Cursors::drawPanel() { + auto showCursor = [](const char* label, const CursorInfo& c, float dispDB) { if (!c.active) { - ImGui::Text("%s: (inactive)", label); + ImGui::TextDisabled("%s: --", label); return; } if (std::abs(c.freq) >= 1e6) - ImGui::Text("%s: %.6f MHz, %.1f dB", label, c.freq / 1e6, c.dB); + ImGui::Text("%s: %.6f MHz, %.1f dB", label, c.freq / 1e6, dispDB); else if (std::abs(c.freq) >= 1e3) - ImGui::Text("%s: %.3f kHz, %.1f dB", label, c.freq / 1e3, c.dB); + ImGui::Text("%s: %.3f kHz, %.1f dB", label, c.freq / 1e3, dispDB); else - ImGui::Text("%s: %.1f Hz, %.1f dB", label, c.freq, c.dB); + ImGui::Text("%s: %.1f Hz, %.1f dB", label, c.freq, dispDB); }; - showCursor("A", cursorA); - showCursor("B", cursorB); + float aDB = avgDBA(), bDB = avgDBB(); + showCursor("A", cursorA, aDB); + showCursor("B", cursorB, bDB); if (cursorA.active && cursorB.active) { double dF = cursorB.freq - cursorA.freq; - float dA = cursorB.dB - cursorA.dB; - ImGui::Separator(); + float dA = bDB - aDB; if (std::abs(dF) >= 1e6) - ImGui::Text("Delta: %.6f MHz, %.1f dB", dF / 1e6, dA); + ImGui::Text("D: %.6f MHz, %.1f dB", dF / 1e6, dA); else if (std::abs(dF) >= 1e3) - ImGui::Text("Delta: %.3f kHz, %.1f dB", dF / 1e3, dA); + ImGui::Text("D: %.3f kHz, %.1f dB", dF / 1e3, dA); else - ImGui::Text("Delta: %.1f Hz, %.1f dB", dF, dA); + ImGui::Text("D: %.1f Hz, %.1f dB", dF, dA); } if (hover.active) { - ImGui::Separator(); if (std::abs(hover.freq) >= 1e6) - ImGui::Text("Hover: %.6f MHz, %.1f dB", hover.freq / 1e6, hover.dB); + ImGui::TextDisabled("%.6f MHz, %.1f dB", hover.freq / 1e6, hover.dB); else if (std::abs(hover.freq) >= 1e3) - ImGui::Text("Hover: %.3f kHz, %.1f dB", hover.freq / 1e3, hover.dB); + ImGui::TextDisabled("%.3f kHz, %.1f dB", hover.freq / 1e3, hover.dB); else - ImGui::Text("Hover: %.1f Hz, %.1f dB", hover.freq, hover.dB); + ImGui::TextDisabled("%.1f Hz, %.1f dB", hover.freq, hover.dB); } + + // Averaging slider (logarithmic scale) + ImGui::SetNextItemWidth(-1); + ImGui::SliderInt("##avgcount", &avgCount, 1, 20000, avgCount == 1 ? "No avg" : "Avg: %d", + ImGuiSliderFlags_Logarithmic); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Cursor averaging (samples)"); } void Cursors::setCursorA(double freq, float dB, int bin) { diff --git a/src/ui/Cursors.h b/src/ui/Cursors.h index f2a0447..f3cd695 100644 --- a/src/ui/Cursors.h +++ b/src/ui/Cursors.h @@ -2,6 +2,7 @@ #include "core/Types.h" #include "ui/SpectrumDisplay.h" +#include #include namespace baudline { @@ -27,7 +28,7 @@ public: float viewLo = 0.0f, float viewHi = 1.0f) const; // Draw cursor readout panel (ImGui widgets). - void drawPanel() const; + void drawPanel(); // Set cursor A/B positions from mouse click. void setCursorA(double freq, float dB, int bin); @@ -47,6 +48,23 @@ public: // Hover cursor (follows mouse, always active) CursorInfo hover; + + // Averaging: displayed dB is the mean of the last N samples. + int avgCount = 1; // 1 = no averaging + + // Averaged dB values (used for display and delta). + float avgDBA() const; + float avgDBB() const; + +private: + // Averaging state per cursor. + struct AvgState { + std::deque samples; + double sum = 0.0; + int lastBin = -1; // reset when cursor moves + }; + mutable AvgState avgA_, avgB_; + void pushAvg(AvgState& st, float dB, int bin) const; }; } // namespace baudline diff --git a/src/ui/WaterfallDisplay.cpp b/src/ui/WaterfallDisplay.cpp index dfc352e..38c667b 100644 --- a/src/ui/WaterfallDisplay.cpp +++ b/src/ui/WaterfallDisplay.cpp @@ -21,6 +21,7 @@ void WaterfallDisplay::init(int binCount, int height) { if (texture_) glDeleteTextures(1, &texture_); glGenTextures(1, &texture_); glBindTexture(GL_TEXTURE_2D, texture_); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // RGB rows may not be 4-byte aligned glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); @@ -32,6 +33,42 @@ void WaterfallDisplay::init(int binCount, int height) { void WaterfallDisplay::resize(int binCount, int height) { if (binCount == width_ && height == height_) return; + + // If width unchanged and height is growing, preserve existing data. + if (binCount == width_ && height > height_ && height_ > 0 && texture_) { + int oldH = height_; + int oldRow = currentRow_; + std::vector oldBuf = std::move(pixelBuf_); + + width_ = binCount; + height_ = height; + pixelBuf_.assign(width_ * height_ * 3, 0); + + // Copy old rows into the new buffer, preserving their circular order. + // Old rows occupy indices 0..oldH-1; new rows oldH..height-1 are black. + // The circular position stays the same since old indices are valid in + // the larger buffer. + int rowBytes = width_ * 3; + for (int r = 0; r < oldH; ++r) + std::memcpy(pixelBuf_.data() + r * rowBytes, + oldBuf.data() + r * rowBytes, rowBytes); + + currentRow_ = oldRow; + + // Recreate texture at new size and upload all data. + if (texture_) glDeleteTextures(1, &texture_); + glGenTextures(1, &texture_); + glBindTexture(GL_TEXTURE_2D, texture_); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width_, height_, 0, + GL_RGB, GL_UNSIGNED_BYTE, pixelBuf_.data()); + return; + } + init(binCount, height); } @@ -116,6 +153,7 @@ void WaterfallDisplay::pushLineMulti( void WaterfallDisplay::uploadRow(int row) { glBindTexture(GL_TEXTURE_2D, texture_); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); glTexSubImage2D(GL_TEXTURE_2D, 0, 0, row, width_, 1, GL_RGB, GL_UNSIGNED_BYTE, pixelBuf_.data() + row * width_ * 3);