commit no. 7

This commit is contained in:
2026-03-25 19:48:24 +01:00
parent cf397eaa2d
commit 7b9a87fbc0
8 changed files with 628 additions and 274 deletions

View File

@@ -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
View 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
View 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

View File

@@ -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()) {
// 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<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
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<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
{
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<int>(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<int>(WindowType::Count))) {
settings_.window = static_cast<WindowType>(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<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::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<int>(WindowType::Count))) {
settings_.window = static_cast<WindowType>(windowIdx_);
updateAnalyzerSettings();
saveConfig();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Window Function");
if (settings_.window == WindowType::Kaiser) {
ImGui::SetNextItemWidth(-1);
if (ImGui::SliderFloat("##kaiser", &settings_.kaiserBeta, 0.0f, 20.0f, "Kaiser: %.1f"))
updateAnalyzerSettings();
}
// Overlap
{
int hopSamples = static_cast<int>(settings_.fftSize * (1.0f - settings_.overlap));
if (hopSamples < 1) hopSamples = 1;
int overlapSamples = settings_.fftSize - hopSamples;
ImGui::SetNextItemWidth(-1);
float sliderVal = 1.0f - std::pow(1.0f - overlapPct_ / 99.0f, 0.25f);
if (ImGui::SliderFloat("##overlap", &sliderVal, 0.0f, 1.0f, "")) {
float inv = 1.0f - sliderVal;
float inv2 = inv * inv;
overlapPct_ = 99.0f * (1.0f - inv2 * inv2);
settings_.overlap = overlapPct_ / 100.0f;
updateAnalyzerSettings();
saveConfig();
}
char overlayText[64];
std::snprintf(overlayText, sizeof(overlayText), "%.1f%% (%d samples)", overlapPct_, overlapSamples);
ImVec2 textSize = ImGui::CalcTextSize(overlayText);
ImVec2 rMin = ImGui::GetItemRectMin();
ImVec2 rMax = ImGui::GetItemRectMax();
float tx = rMin.x + ((rMax.x - rMin.x) - textSize.x) * 0.5f;
float ty = rMin.y + ((rMax.y - rMin.y) - textSize.y) * 0.5f;
ImGui::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<int>(ColorMapType::Count))) {
colorMap_.setType(static_cast<ColorMapType>(colorMapIdx_));
waterfall_.setColorMap(colorMap_);
saveConfig();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Color Map");
if (nCh > 1) {
ImGui::SetNextItemWidth(-1);
if (ImGui::SliderInt("##wfch", &waterfallChannel_, 0, nCh - 1))
waterfallChannel_ = std::clamp(waterfallChannel_, 0, nCh - 1);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Waterfall Channel");
}
}
}
}
// ── Math ──
ImGui::Spacing();
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<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

View File

@@ -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 01 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;

View File

@@ -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) {

View File

@@ -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

View File

@@ -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);