commit no. 7
This commit is contained in:
@@ -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
|
||||
|
||||
105
src/core/Config.cpp
Normal file
105
src/core/Config.cpp
Normal file
@@ -0,0 +1,105 @@
|
||||
#include "core/Config.h"
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <sys/stat.h>
|
||||
|
||||
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
|
||||
32
src/core/Config.h
Normal file
32
src/core/Config.h
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
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<std::string, std::string> data_;
|
||||
std::string resolvedPath(const std::string& path) const;
|
||||
};
|
||||
|
||||
} // namespace baudline
|
||||
@@ -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<WindowType>(windowIdx_);
|
||||
@@ -243,22 +246,94 @@ void Application::render() {
|
||||
|
||||
// Menu bar
|
||||
if (ImGui::BeginMenuBar()) {
|
||||
if (ImGui::BeginMenu("File")) {
|
||||
if (ImGui::MenuItem("Open WAV...")) {
|
||||
// TODO: file dialog integration
|
||||
// Sidebar toggle (leftmost)
|
||||
if (ImGui::Button(showSidebar_ ? " << " : " >> ")) {
|
||||
showSidebar_ = !showSidebar_;
|
||||
saveConfig();
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(showSidebar_ ? "Hide sidebar" : "Show sidebar");
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
if (ImGui::BeginMenu("File")) {
|
||||
// ── File input ──
|
||||
static char filePathBuf[512] = "";
|
||||
if (filePath_.size() < sizeof(filePathBuf))
|
||||
std::strncpy(filePathBuf, filePath_.c_str(), sizeof(filePathBuf) - 1);
|
||||
ImGui::SetNextItemWidth(200);
|
||||
if (ImGui::InputText("Path", filePathBuf, sizeof(filePathBuf)))
|
||||
filePath_ = filePathBuf;
|
||||
|
||||
const char* formatNames[] = {"Float32 I/Q", "Int16 I/Q", "Uint8 I/Q", "WAV"};
|
||||
ImGui::SetNextItemWidth(140);
|
||||
ImGui::Combo("Format", &fileFormatIdx_, formatNames, 4);
|
||||
|
||||
ImGui::SetNextItemWidth(140);
|
||||
ImGui::DragFloat("Sample Rate", &fileSampleRate_, 1000.0f, 1000.0f, 100e6f, "%.0f Hz");
|
||||
ImGui::Checkbox("Loop", &fileLoop_);
|
||||
|
||||
if (ImGui::MenuItem("Open File")) {
|
||||
InputFormat fmt;
|
||||
switch (fileFormatIdx_) {
|
||||
case 0: fmt = InputFormat::Float32IQ; break;
|
||||
case 1: fmt = InputFormat::Int16IQ; break;
|
||||
case 2: fmt = InputFormat::Uint8IQ; break;
|
||||
default: fmt = InputFormat::WAV; break;
|
||||
}
|
||||
openFile(filePath_, fmt, fileSampleRate_);
|
||||
updateAnalyzerSettings();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// ── Audio device ──
|
||||
if (!paDevices_.empty()) {
|
||||
ImGui::Text("Audio Device");
|
||||
std::vector<const char*> devNames;
|
||||
for (auto& d : paDevices_) devNames.push_back(d.name.c_str());
|
||||
ImGui::SetNextItemWidth(250);
|
||||
if (ImGui::Combo("##device", &paDeviceIdx_, devNames.data(),
|
||||
static_cast<int>(devNames.size()))) {
|
||||
openPortAudio();
|
||||
updateAnalyzerSettings();
|
||||
saveConfig();
|
||||
}
|
||||
}
|
||||
if (ImGui::MenuItem("Open 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<int>(freqScale_);
|
||||
const char* fsNames[] = {"Linear", "Logarithmic"};
|
||||
ImGui::SetNextItemWidth(120);
|
||||
if (ImGui::Combo("Freq Scale", &fs, fsNames, 2)) {
|
||||
freqScale_ = static_cast<FreqScale>(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
|
||||
// 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,173 +464,138 @@ 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();
|
||||
// ── Playback ──
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3.0f;
|
||||
if (ImGui::Button(paused_ ? "Resume" : "Pause", {btnW, 0}))
|
||||
paused_ = !paused_;
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Clear", {btnW, 0}))
|
||||
analyzer_.clearHistory();
|
||||
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");
|
||||
|
||||
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<const char*> devNames;
|
||||
for (auto& d : paDevices_) devNames.push_back(d.name.c_str());
|
||||
if (ImGui::Combo("Device", &paDeviceIdx_, devNames.data(),
|
||||
static_cast<int>(devNames.size()))) {
|
||||
openPortAudio();
|
||||
updateAnalyzerSettings();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
ImGui::Text("FFT Settings");
|
||||
|
||||
// FFT size
|
||||
{
|
||||
// ── FFT ──
|
||||
ImGui::Spacing();
|
||||
if (ImGui::CollapsingHeader("FFT", ImGuiTreeNodeFlags_DefaultOpen)) {
|
||||
const char* sizeNames[] = {"256", "512", "1024", "2048", "4096",
|
||||
"8192", "16384", "32768", "65536"};
|
||||
if (ImGui::Combo("FFT Size", &fftSizeIdx_, sizeNames, kNumFFTSizes)) {
|
||||
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<int>(WindowType::Count))) {
|
||||
settings_.window = static_cast<WindowType>(windowIdx_);
|
||||
updateAnalyzerSettings();
|
||||
saveConfig();
|
||||
}
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Window Function");
|
||||
|
||||
if (settings_.window == WindowType::Kaiser) {
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
if (ImGui::SliderFloat("##kaiser", &settings_.kaiserBeta, 0.0f, 20.0f, "Kaiser: %.1f"))
|
||||
updateAnalyzerSettings();
|
||||
}
|
||||
|
||||
// Overlap — inverted x⁴ curve: sensitive at the high end (90%+).
|
||||
// Overlap
|
||||
{
|
||||
int hopSamples = static_cast<int>(settings_.fftSize * (1.0f - settings_.overlap));
|
||||
if (hopSamples < 1) hopSamples = 1;
|
||||
int overlapSamples = settings_.fftSize - hopSamples;
|
||||
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
float sliderVal = 1.0f - std::pow(1.0f - overlapPct_ / 99.0f, 0.25f);
|
||||
if (ImGui::SliderFloat("Overlap", &sliderVal, 0.0f, 1.0f, "")) {
|
||||
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();
|
||||
}
|
||||
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
|
||||
// Window function
|
||||
{
|
||||
const char* winNames[] = {"Rectangular", "Hann", "Hamming", "Blackman",
|
||||
"Blackman-Harris", "Kaiser", "Flat Top"};
|
||||
if (ImGui::Combo("Window", &windowIdx_, winNames,
|
||||
static_cast<int>(WindowType::Count))) {
|
||||
settings_.window = static_cast<WindowType>(windowIdx_);
|
||||
if (settings_.window == WindowType::Kaiser) {
|
||||
// Show Kaiser beta slider
|
||||
}
|
||||
updateAnalyzerSettings();
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Overlap");
|
||||
}
|
||||
}
|
||||
|
||||
if (settings_.window == WindowType::Kaiser) {
|
||||
if (ImGui::SliderFloat("Kaiser Beta", &settings_.kaiserBeta, 0.0f, 20.0f)) {
|
||||
updateAnalyzerSettings();
|
||||
}
|
||||
}
|
||||
// ── 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::Separator();
|
||||
ImGui::Text("Display");
|
||||
|
||||
// Color map
|
||||
{
|
||||
const char* cmNames[] = {"Magma", "Viridis", "Inferno", "Plasma", "Grayscale"};
|
||||
if (ImGui::Combo("Color Map", &colorMapIdx_, cmNames,
|
||||
static_cast<int>(ColorMapType::Count))) {
|
||||
colorMap_.setType(static_cast<ColorMapType>(colorMapIdx_));
|
||||
waterfall_.setColorMap(colorMap_);
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency scale
|
||||
{
|
||||
int fs = static_cast<int>(freqScale_);
|
||||
const char* fsNames[] = {"Linear", "Logarithmic"};
|
||||
if (ImGui::Combo("Freq Scale", &fs, fsNames, 2))
|
||||
freqScale_ = static_cast<FreqScale>(fs);
|
||||
}
|
||||
|
||||
// Zoom info & reset
|
||||
if (viewLo_ > 0.0f || viewHi_ < 1.0f) {
|
||||
float zoomPct = 1.0f / (viewHi_ - viewLo_);
|
||||
ImGui::Text("Zoom: %.1fx", zoomPct);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Reset")) {
|
||||
viewLo_ = 0.0f;
|
||||
viewHi_ = 1.0f;
|
||||
}
|
||||
}
|
||||
ImGui::TextDisabled("Scroll: freq zoom | MMB drag: pan");
|
||||
ImGui::TextDisabled("Ctrl+Scroll: dB zoom | MMB dbl: reset");
|
||||
|
||||
// dB range
|
||||
ImGui::DragFloatRange2("dB Range", &minDB_, &maxDB_, 1.0f, -200.0f, 20.0f,
|
||||
"Min: %.0f", "Max: %.0f");
|
||||
|
||||
// Peak hold
|
||||
ImGui::Checkbox("Peak Hold", &specDisplay_.peakHoldEnable);
|
||||
if (specDisplay_.peakHoldEnable) {
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(80);
|
||||
ImGui::SliderFloat("Decay", &specDisplay_.peakHoldDecay, 0.0f, 120.0f, "%.0f dB/s");
|
||||
ImGui::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();
|
||||
}
|
||||
|
||||
// Channel colors (only shown for multi-channel)
|
||||
int nCh = analyzer_.numSpectra();
|
||||
if (nCh > 1) {
|
||||
ImGui::Separator();
|
||||
ImGui::Text("Channels (%d)", nCh);
|
||||
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"
|
||||
};
|
||||
@@ -547,61 +607,54 @@ void Application::renderControlPanel() {
|
||||
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<int>(ColorMapType::Count))) {
|
||||
colorMap_.setType(static_cast<ColorMapType>(colorMapIdx_));
|
||||
waterfall_.setColorMap(colorMap_);
|
||||
saveConfig();
|
||||
}
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Color Map");
|
||||
|
||||
// Waterfall mode
|
||||
ImGui::Checkbox("Multi-Ch Waterfall", &waterfallMultiCh_);
|
||||
if (!waterfallMultiCh_) {
|
||||
if (ImGui::SliderInt("Waterfall Ch", &waterfallChannel_, 0, nCh - 1))
|
||||
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 channels section (always shown).
|
||||
ImGui::Separator();
|
||||
// ── Math ──
|
||||
ImGui::Spacing();
|
||||
if (ImGui::CollapsingHeader("Math")) {
|
||||
renderMathPanel();
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// Playback controls
|
||||
if (ImGui::Button(paused_ ? "Resume [Space]" : "Pause [Space]"))
|
||||
paused_ = !paused_;
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Clear")) {
|
||||
analyzer_.clearHistory();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// Cursors
|
||||
// ── Cursors ──
|
||||
ImGui::Spacing();
|
||||
if (ImGui::CollapsingHeader("Cursors", ImGuiTreeNodeFlags_DefaultOpen)) {
|
||||
cursors_.drawPanel();
|
||||
|
||||
ImGui::Separator();
|
||||
if (ImGui::Button("Snap to Peak [P]")) {
|
||||
int pkCh = std::clamp(waterfallChannel_, 0, analyzer_.numSpectra() - 1);
|
||||
cursors_.snapToPeak(analyzer_.channelSpectrum(pkCh),
|
||||
settings_.sampleRate, settings_.isIQ,
|
||||
settings_.fftSize);
|
||||
}
|
||||
|
||||
// Status
|
||||
// ── 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<int>(freqScale_));
|
||||
freqScale_ = static_cast<FreqScale>(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<int>(WindowType::Count) - 1);
|
||||
colorMapIdx_ = std::clamp(colorMapIdx_, 0, static_cast<int>(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<int>(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<WindowType>(windowIdx_);
|
||||
colorMap_.setType(static_cast<ColorMapType>(colorMapIdx_));
|
||||
SDL_GL_SetSwapInterval(vsync_ ? 1 : 0);
|
||||
}
|
||||
|
||||
void Application::saveConfig() const {
|
||||
Config cfg;
|
||||
cfg.setInt("fft_size_idx", fftSizeIdx_);
|
||||
cfg.setFloat("overlap_pct", overlapPct_);
|
||||
cfg.setInt("window_idx", windowIdx_);
|
||||
cfg.setInt("colormap_idx", colorMapIdx_);
|
||||
cfg.setFloat("min_db", minDB_);
|
||||
cfg.setFloat("max_db", maxDB_);
|
||||
cfg.setInt("freq_scale", static_cast<int>(freqScale_));
|
||||
cfg.setBool("vsync", vsync_);
|
||||
cfg.setFloat("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<int>(paDevices_.size()))
|
||||
cfg.setString("device_name", paDevices_[paDeviceIdx_].name);
|
||||
|
||||
cfg.save();
|
||||
}
|
||||
|
||||
} // namespace baudline
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<int>(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<float>(avgA_.sum / avgA_.samples.size());
|
||||
}
|
||||
|
||||
float Cursors::avgDBB() const {
|
||||
return avgB_.samples.empty() ? cursorB.dB
|
||||
: static_cast<float>(avgB_.sum / avgB_.samples.size());
|
||||
}
|
||||
|
||||
void Cursors::update(const std::vector<float>& spectrumDB,
|
||||
double sampleRate, bool isIQ, int fftSize) {
|
||||
// Update dB values at cursor bin positions
|
||||
if (cursorA.active && cursorA.bin >= 0 &&
|
||||
cursorA.bin < static_cast<int>(spectrumDB.size())) {
|
||||
cursorA.dB = spectrumDB[cursorA.bin];
|
||||
pushAvg(avgA_, cursorA.dB, cursorA.bin);
|
||||
}
|
||||
if (cursorB.active && cursorB.bin >= 0 &&
|
||||
cursorB.bin < static_cast<int>(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) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "core/Types.h"
|
||||
#include "ui/SpectrumDisplay.h"
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
|
||||
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<float> 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
|
||||
|
||||
@@ -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<uint8_t> 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);
|
||||
|
||||
Reference in New Issue
Block a user