initial commit
This commit is contained in:
718
src/ui/Application.cpp
Normal file
718
src/ui/Application.cpp
Normal file
@@ -0,0 +1,718 @@
|
||||
#include "ui/Application.h"
|
||||
#include "audio/FileSource.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <imgui_impl_sdl2.h>
|
||||
#include <imgui_impl_opengl3.h>
|
||||
|
||||
#include <GL/gl.h>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
Application::Application() = default;
|
||||
|
||||
Application::~Application() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
bool Application::init(int argc, char** argv) {
|
||||
// Parse command line: baudline [file] [--format fmt] [--rate sr]
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string arg = argv[i];
|
||||
if (arg == "--format" && i + 1 < argc) {
|
||||
std::string fmt = argv[++i];
|
||||
if (fmt == "f32") fileFormatIdx_ = 0;
|
||||
if (fmt == "i16") fileFormatIdx_ = 1;
|
||||
if (fmt == "u8") fileFormatIdx_ = 2;
|
||||
if (fmt == "wav") fileFormatIdx_ = 3;
|
||||
} else if (arg == "--rate" && i + 1 < argc) {
|
||||
fileSampleRate_ = std::stof(argv[++i]);
|
||||
} else if (arg == "--iq") {
|
||||
settings_.isIQ = true;
|
||||
} else if (arg[0] != '-') {
|
||||
filePath_ = arg;
|
||||
}
|
||||
}
|
||||
|
||||
// SDL init
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
|
||||
std::fprintf(stderr, "SDL_Init error: %s\n", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
|
||||
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
||||
|
||||
window_ = SDL_CreateWindow("Baudline Spectrum Analyzer",
|
||||
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
1400, 900,
|
||||
SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE |
|
||||
SDL_WINDOW_ALLOW_HIGHDPI);
|
||||
if (!window_) {
|
||||
std::fprintf(stderr, "SDL_CreateWindow error: %s\n", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
glContext_ = SDL_GL_CreateContext(window_);
|
||||
SDL_GL_MakeCurrent(window_, glContext_);
|
||||
SDL_GL_SetSwapInterval(1); // vsync
|
||||
|
||||
// ImGui init
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
|
||||
ImGui::StyleColorsDark();
|
||||
ImGuiStyle& style = ImGui::GetStyle();
|
||||
style.WindowRounding = 4.0f;
|
||||
style.FrameRounding = 2.0f;
|
||||
style.GrabRounding = 2.0f;
|
||||
|
||||
ImGui_ImplSDL2_InitForOpenGL(window_, glContext_);
|
||||
ImGui_ImplOpenGL3_Init("#version 120");
|
||||
|
||||
// Enumerate audio devices
|
||||
paDevices_ = PortAudioSource::listInputDevices();
|
||||
|
||||
// Default settings
|
||||
settings_.fftSize = kFFTSizes[fftSizeIdx_];
|
||||
settings_.overlap = overlapPct_ / 100.0f;
|
||||
settings_.window = static_cast<WindowType>(windowIdx_);
|
||||
settings_.sampleRate = fileSampleRate_;
|
||||
settings_.isIQ = false;
|
||||
|
||||
// Open source
|
||||
if (!filePath_.empty()) {
|
||||
InputFormat fmt;
|
||||
switch (fileFormatIdx_) {
|
||||
case 0: fmt = InputFormat::Float32IQ; settings_.isIQ = true; break;
|
||||
case 1: fmt = InputFormat::Int16IQ; settings_.isIQ = true; break;
|
||||
case 2: fmt = InputFormat::Uint8IQ; settings_.isIQ = true; break;
|
||||
default: fmt = InputFormat::WAV; break;
|
||||
}
|
||||
openFile(filePath_, fmt, fileSampleRate_);
|
||||
} else {
|
||||
openPortAudio();
|
||||
}
|
||||
|
||||
updateAnalyzerSettings();
|
||||
|
||||
running_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Application::run() {
|
||||
while (running_) {
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
ImGui_ImplSDL2_ProcessEvent(&event);
|
||||
if (event.type == SDL_QUIT)
|
||||
running_ = false;
|
||||
if (event.type == SDL_KEYDOWN) {
|
||||
auto key = event.key.keysym.sym;
|
||||
if (key == SDLK_ESCAPE) running_ = false;
|
||||
if (key == SDLK_SPACE) paused_ = !paused_;
|
||||
if (key == SDLK_p) {
|
||||
int pkCh = std::clamp(waterfallChannel_, 0,
|
||||
analyzer_.numSpectra() - 1);
|
||||
cursors_.snapToPeak(analyzer_.channelSpectrum(pkCh),
|
||||
settings_.sampleRate, settings_.isIQ,
|
||||
settings_.fftSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!paused_)
|
||||
processAudio();
|
||||
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
void Application::shutdown() {
|
||||
if (audioSource_) {
|
||||
audioSource_->close();
|
||||
audioSource_.reset();
|
||||
}
|
||||
|
||||
ImGui_ImplOpenGL3_Shutdown();
|
||||
ImGui_ImplSDL2_Shutdown();
|
||||
ImGui::DestroyContext();
|
||||
|
||||
if (glContext_) {
|
||||
SDL_GL_DeleteContext(glContext_);
|
||||
glContext_ = nullptr;
|
||||
}
|
||||
if (window_) {
|
||||
SDL_DestroyWindow(window_);
|
||||
window_ = nullptr;
|
||||
}
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
void Application::processAudio() {
|
||||
if (!audioSource_) return;
|
||||
|
||||
int channels = audioSource_->channels();
|
||||
// Read in hop-sized chunks, process up to a limited number of spectra per
|
||||
// frame to avoid freezing the UI when a large backlog has accumulated.
|
||||
size_t hopFrames = static_cast<size_t>(
|
||||
settings_.fftSize * (1.0f - settings_.overlap));
|
||||
if (hopFrames < 1) hopFrames = 1;
|
||||
size_t framesToRead = hopFrames;
|
||||
audioBuf_.resize(framesToRead * channels);
|
||||
|
||||
constexpr int kMaxSpectraPerFrame = 8;
|
||||
int spectraThisFrame = 0;
|
||||
|
||||
while (spectraThisFrame < kMaxSpectraPerFrame) {
|
||||
size_t framesRead = audioSource_->read(audioBuf_.data(), framesToRead);
|
||||
if (framesRead == 0) break;
|
||||
|
||||
analyzer_.pushSamples(audioBuf_.data(), framesRead);
|
||||
|
||||
if (analyzer_.hasNewSpectrum()) {
|
||||
int nSpec = analyzer_.numSpectra();
|
||||
if (waterfallMultiCh_ && nSpec > 1) {
|
||||
// Multi-channel overlay waterfall.
|
||||
std::vector<WaterfallChannelInfo> wfChInfo(nSpec);
|
||||
for (int ch = 0; ch < nSpec; ++ch) {
|
||||
const auto& c = channelColors_[ch % kMaxChannels];
|
||||
wfChInfo[ch] = {c.x, c.y, c.z,
|
||||
channelEnabled_[ch % kMaxChannels]};
|
||||
}
|
||||
waterfall_.pushLineMulti(analyzer_.allSpectra(),
|
||||
wfChInfo, minDB_, maxDB_);
|
||||
} else {
|
||||
int wfCh = std::clamp(waterfallChannel_, 0, nSpec - 1);
|
||||
waterfall_.pushLine(analyzer_.channelSpectrum(wfCh),
|
||||
minDB_, maxDB_);
|
||||
}
|
||||
int curCh = std::clamp(waterfallChannel_, 0, nSpec - 1);
|
||||
cursors_.update(analyzer_.channelSpectrum(curCh),
|
||||
settings_.sampleRate, settings_.isIQ, settings_.fftSize);
|
||||
++spectraThisFrame;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioSource_->isEOF() && !audioSource_->isRealTime()) {
|
||||
paused_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void Application::render() {
|
||||
// Skip rendering entirely when the window is minimized — the drawable
|
||||
// size is 0, which would create zero-sized GL textures and divide-by-zero
|
||||
// in layout calculations.
|
||||
if (SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED) {
|
||||
SDL_Delay(16);
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplSDL2_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
||||
// Full-screen layout
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(viewport->WorkPos);
|
||||
ImGui::SetNextWindowSize(viewport->WorkSize);
|
||||
ImGui::Begin("##Main", nullptr,
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus |
|
||||
ImGuiWindowFlags_MenuBar);
|
||||
|
||||
// Menu bar
|
||||
if (ImGui::BeginMenuBar()) {
|
||||
if (ImGui::BeginMenu("File")) {
|
||||
if (ImGui::MenuItem("Open WAV...")) {
|
||||
// TODO: file dialog integration
|
||||
}
|
||||
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::EndMenu();
|
||||
}
|
||||
ImGui::EndMenuBar();
|
||||
}
|
||||
|
||||
// Layout: controls on left (250px), spectrum+waterfall on right
|
||||
float controlW = 260.0f;
|
||||
float contentW = ImGui::GetContentRegionAvail().x - controlW - 8;
|
||||
float contentH = ImGui::GetContentRegionAvail().y;
|
||||
|
||||
// Control panel
|
||||
ImGui::BeginChild("Controls", {controlW, contentH}, true);
|
||||
renderControlPanel();
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
// Spectrum + Waterfall
|
||||
ImGui::BeginChild("Display", {contentW, contentH}, false);
|
||||
float specH = contentH * 0.35f;
|
||||
float waterfH = contentH * 0.65f - 4;
|
||||
|
||||
renderSpectrumPanel();
|
||||
renderWaterfallPanel();
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::End();
|
||||
|
||||
// Render
|
||||
ImGui::Render();
|
||||
int displayW, displayH;
|
||||
SDL_GL_GetDrawableSize(window_, &displayW, &displayH);
|
||||
glViewport(0, 0, displayW, displayH);
|
||||
glClearColor(0.08f, 0.08f, 0.10f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
||||
SDL_GL_SwapWindow(window_);
|
||||
}
|
||||
|
||||
void Application::renderControlPanel() {
|
||||
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
|
||||
if (ImGui::SliderFloat("Overlap", &overlapPct_, 0.0f, 95.0f, "%.1f%%")) {
|
||||
settings_.overlap = overlapPct_ / 100.0f;
|
||||
updateAnalyzerSettings();
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// Averaging
|
||||
ImGui::SliderInt("Averaging", &settings_.averaging, 1, 32);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// dB range
|
||||
ImGui::DragFloatRange2("dB Range", &minDB_, &maxDB_, 1.0f, -200.0f, 20.0f,
|
||||
"Min: %.0f", "Max: %.0f");
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
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_.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
|
||||
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"));
|
||||
|
||||
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);
|
||||
else if (std::abs(peakFreq) >= 1e3)
|
||||
ImGui::Text("Peak: %.3f kHz, %.1f dB", peakFreq / 1e3, peakDB);
|
||||
else
|
||||
ImGui::Text("Peak: %.1f Hz, %.1f dB", peakFreq, peakDB);
|
||||
}
|
||||
|
||||
void Application::renderSpectrumPanel() {
|
||||
float availW = ImGui::GetContentRegionAvail().x;
|
||||
float specH = ImGui::GetContentRegionAvail().y * 0.35f;
|
||||
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
specPosX_ = pos.x;
|
||||
specPosY_ = pos.y;
|
||||
specSizeX_ = availW;
|
||||
specSizeY_ = specH;
|
||||
|
||||
// Build per-channel styles and pass all spectra.
|
||||
int nCh = analyzer_.numSpectra();
|
||||
std::vector<ChannelStyle> styles(nCh);
|
||||
for (int ch = 0; ch < nCh; ++ch) {
|
||||
const auto& c = channelColors_[ch % kMaxChannels];
|
||||
uint8_t r = static_cast<uint8_t>(c.x * 255);
|
||||
uint8_t g = static_cast<uint8_t>(c.y * 255);
|
||||
uint8_t b = static_cast<uint8_t>(c.z * 255);
|
||||
styles[ch].lineColor = IM_COL32(r, g, b, 220);
|
||||
styles[ch].fillColor = IM_COL32(r, g, b, 35);
|
||||
}
|
||||
specDisplay_.draw(analyzer_.allSpectra(), styles, minDB_, maxDB_,
|
||||
settings_.sampleRate, settings_.isIQ, freqScale_,
|
||||
specPosX_, specPosY_, specSizeX_, specSizeY_);
|
||||
|
||||
cursors_.draw(specDisplay_, specPosX_, specPosY_, specSizeX_, specSizeY_,
|
||||
settings_.sampleRate, settings_.isIQ, freqScale_, minDB_, maxDB_);
|
||||
|
||||
handleSpectrumInput(specPosX_, specPosY_, specSizeX_, specSizeY_);
|
||||
|
||||
ImGui::Dummy({availW, specH});
|
||||
}
|
||||
|
||||
void Application::renderWaterfallPanel() {
|
||||
float availW = ImGui::GetContentRegionAvail().x;
|
||||
float availH = ImGui::GetContentRegionAvail().y;
|
||||
|
||||
int newW = static_cast<int>(availW);
|
||||
int newH = static_cast<int>(availH);
|
||||
if (newW < 1) newW = 1;
|
||||
if (newH < 1) newH = 1;
|
||||
|
||||
if (newW != waterfallW_ || newH != waterfallH_) {
|
||||
waterfallW_ = newW;
|
||||
waterfallH_ = newH;
|
||||
waterfall_.resize(waterfallW_, waterfallH_);
|
||||
waterfall_.setColorMap(colorMap_);
|
||||
}
|
||||
|
||||
if (waterfall_.textureID()) {
|
||||
// Render waterfall texture with circular buffer offset.
|
||||
// The texture rows wrap: currentRow_ is where the *next* line will go,
|
||||
// so the *newest* line is at currentRow_+1.
|
||||
float rowFrac = static_cast<float>(waterfall_.currentRow() + 1) /
|
||||
waterfall_.height();
|
||||
|
||||
// UV coordinates: bottom of display = newest = rowFrac
|
||||
// top of display = oldest = rowFrac + 1.0 (wraps)
|
||||
// We'll use two draw calls to handle the wrap, or use GL_REPEAT.
|
||||
// Simplest: just render with ImGui::Image and accept minor visual glitch,
|
||||
// or split into two parts.
|
||||
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
auto texID = static_cast<ImTextureID>(waterfall_.textureID());
|
||||
|
||||
int h = waterfall_.height();
|
||||
int cur = (waterfall_.currentRow() + 1) % h;
|
||||
float splitFrac = static_cast<float>(h - cur) / h;
|
||||
|
||||
// Top part: rows from cur to h-1 (oldest)
|
||||
float topH = availH * splitFrac;
|
||||
dl->AddImage(texID,
|
||||
{pos.x, pos.y},
|
||||
{pos.x + availW, pos.y + topH},
|
||||
{0.0f, static_cast<float>(cur) / h},
|
||||
{1.0f, 1.0f});
|
||||
|
||||
// Bottom part: rows from 0 to cur-1 (newest)
|
||||
if (cur > 0) {
|
||||
dl->AddImage(texID,
|
||||
{pos.x, pos.y + topH},
|
||||
{pos.x + availW, pos.y + availH},
|
||||
{0.0f, 0.0f},
|
||||
{1.0f, static_cast<float>(cur) / h});
|
||||
}
|
||||
|
||||
// Frequency axis labels at bottom
|
||||
ImU32 textCol = IM_COL32(180, 180, 200, 200);
|
||||
double freqMin = settings_.isIQ ? -settings_.sampleRate / 2.0 : 0.0;
|
||||
double freqMax = settings_.isIQ ? settings_.sampleRate / 2.0 : settings_.sampleRate / 2.0;
|
||||
int numLabels = 8;
|
||||
for (int i = 0; i <= numLabels; ++i) {
|
||||
float frac = static_cast<float>(i) / numLabels;
|
||||
double freq = freqMin + frac * (freqMax - freqMin);
|
||||
float x = pos.x + frac * availW;
|
||||
|
||||
char label[32];
|
||||
if (std::abs(freq) >= 1e6)
|
||||
std::snprintf(label, sizeof(label), "%.2fM", freq / 1e6);
|
||||
else if (std::abs(freq) >= 1e3)
|
||||
std::snprintf(label, sizeof(label), "%.1fk", freq / 1e3);
|
||||
else
|
||||
std::snprintf(label, sizeof(label), "%.0f", freq);
|
||||
|
||||
dl->AddText({x + 2, pos.y + availH - 14}, textCol, label);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Dummy({availW, availH});
|
||||
}
|
||||
|
||||
void Application::handleSpectrumInput(float posX, float posY,
|
||||
float sizeX, float sizeY) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
float mx = io.MousePos.x;
|
||||
float my = io.MousePos.y;
|
||||
|
||||
bool inRegion = mx >= posX && mx <= posX + sizeX &&
|
||||
my >= posY && my <= posY + sizeY;
|
||||
|
||||
if (inRegion) {
|
||||
// Update hover cursor
|
||||
double freq = specDisplay_.screenXToFreq(mx, posX, sizeX,
|
||||
settings_.sampleRate,
|
||||
settings_.isIQ, freqScale_);
|
||||
float dB = specDisplay_.screenYToDB(my, posY, sizeY, minDB_, maxDB_);
|
||||
|
||||
// Find closest bin
|
||||
int bins = analyzer_.spectrumSize();
|
||||
double freqMin = settings_.isIQ ? -settings_.sampleRate / 2.0 : 0.0;
|
||||
double freqMax = settings_.isIQ ? settings_.sampleRate / 2.0 : settings_.sampleRate / 2.0;
|
||||
int bin = static_cast<int>((freq - freqMin) / (freqMax - freqMin) * (bins - 1));
|
||||
bin = std::clamp(bin, 0, bins - 1);
|
||||
|
||||
int curCh = std::clamp(waterfallChannel_, 0, analyzer_.numSpectra() - 1);
|
||||
const auto& spec = analyzer_.channelSpectrum(curCh);
|
||||
if (!spec.empty()) {
|
||||
dB = spec[bin];
|
||||
cursors_.hover = {true, freq, dB, bin};
|
||||
}
|
||||
|
||||
// Left click: cursor A
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !io.WantCaptureMouse) {
|
||||
int peakBin = cursors_.findLocalPeak(spec, bin, 10);
|
||||
double peakFreq = analyzer_.binToFreq(peakBin);
|
||||
cursors_.setCursorA(peakFreq, spec[peakBin], peakBin);
|
||||
}
|
||||
// Right click: cursor B
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !io.WantCaptureMouse) {
|
||||
int peakBin = cursors_.findLocalPeak(spec, bin, 10);
|
||||
double peakFreq = analyzer_.binToFreq(peakBin);
|
||||
cursors_.setCursorB(peakFreq, spec[peakBin], peakBin);
|
||||
}
|
||||
|
||||
// Scroll: zoom dB range
|
||||
if (io.MouseWheel != 0 && !io.WantCaptureMouse) {
|
||||
float zoom = io.MouseWheel * 5.0f;
|
||||
minDB_ += zoom;
|
||||
maxDB_ -= zoom;
|
||||
if (maxDB_ - minDB_ < 10.0f) {
|
||||
float mid = (minDB_ + maxDB_) / 2.0f;
|
||||
minDB_ = mid - 5.0f;
|
||||
maxDB_ = mid + 5.0f;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cursors_.hover.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
void Application::openPortAudio() {
|
||||
if (audioSource_) audioSource_->close();
|
||||
|
||||
int deviceIdx = -1;
|
||||
double sr = 48000.0;
|
||||
if (paDeviceIdx_ >= 0 && paDeviceIdx_ < static_cast<int>(paDevices_.size())) {
|
||||
deviceIdx = paDevices_[paDeviceIdx_].index;
|
||||
sr = paDevices_[paDeviceIdx_].defaultSampleRate;
|
||||
}
|
||||
|
||||
// Request stereo (or max available) so we can show per-channel spectra.
|
||||
int reqCh = 2;
|
||||
if (paDeviceIdx_ >= 0 && paDeviceIdx_ < static_cast<int>(paDevices_.size()))
|
||||
reqCh = std::min(paDevices_[paDeviceIdx_].maxInputChannels, kMaxChannels);
|
||||
if (reqCh < 1) reqCh = 1;
|
||||
auto src = std::make_unique<PortAudioSource>(sr, reqCh, deviceIdx);
|
||||
if (src->open()) {
|
||||
audioSource_ = std::move(src);
|
||||
settings_.sampleRate = sr;
|
||||
settings_.isIQ = false;
|
||||
settings_.numChannels = audioSource_->channels();
|
||||
} else {
|
||||
std::fprintf(stderr, "Failed to open PortAudio device\n");
|
||||
}
|
||||
}
|
||||
|
||||
void Application::openFile(const std::string& path, InputFormat format, double sampleRate) {
|
||||
if (audioSource_) audioSource_->close();
|
||||
|
||||
bool isIQ = (format != InputFormat::WAV);
|
||||
auto src = std::make_unique<FileSource>(path, format, sampleRate, fileLoop_);
|
||||
if (src->open()) {
|
||||
settings_.sampleRate = src->sampleRate();
|
||||
settings_.isIQ = isIQ;
|
||||
settings_.numChannels = isIQ ? 1 : src->channels();
|
||||
audioSource_ = std::move(src);
|
||||
fileSampleRate_ = static_cast<float>(settings_.sampleRate);
|
||||
} else {
|
||||
std::fprintf(stderr, "Failed to open file: %s\n", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void Application::updateAnalyzerSettings() {
|
||||
int oldFFTSize = settings_.fftSize;
|
||||
bool oldIQ = settings_.isIQ;
|
||||
int oldNCh = settings_.numChannels;
|
||||
|
||||
settings_.fftSize = kFFTSizes[fftSizeIdx_];
|
||||
settings_.overlap = overlapPct_ / 100.0f;
|
||||
settings_.window = static_cast<WindowType>(windowIdx_);
|
||||
analyzer_.configure(settings_);
|
||||
|
||||
bool sizeChanged = settings_.fftSize != oldFFTSize ||
|
||||
settings_.isIQ != oldIQ ||
|
||||
settings_.numChannels != oldNCh;
|
||||
|
||||
if (sizeChanged) {
|
||||
// Drain any stale audio data from the ring buffer so a backlog from
|
||||
// the reconfigure doesn't flood the new analyzer.
|
||||
if (audioSource_ && audioSource_->isRealTime()) {
|
||||
int channels = audioSource_->channels();
|
||||
std::vector<float> drain(4096 * channels);
|
||||
while (audioSource_->read(drain.data(), 4096) > 0) {}
|
||||
}
|
||||
|
||||
// Invalidate cursor bin indices — they refer to the old FFT size.
|
||||
cursors_.cursorA.active = false;
|
||||
cursors_.cursorB.active = false;
|
||||
|
||||
// Re-init waterfall texture so the old image from a different FFT
|
||||
// size doesn't persist.
|
||||
if (waterfallW_ > 0 && waterfallH_ > 0)
|
||||
waterfall_.init(waterfallW_, waterfallH_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace baudline
|
||||
110
src/ui/Application.h
Normal file
110
src/ui/Application.h
Normal file
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/Types.h"
|
||||
#include "dsp/SpectrumAnalyzer.h"
|
||||
#include "audio/AudioSource.h"
|
||||
#include "audio/PortAudioSource.h"
|
||||
#include "ui/ColorMap.h"
|
||||
#include "ui/WaterfallDisplay.h"
|
||||
#include "ui/SpectrumDisplay.h"
|
||||
#include "ui/Cursors.h"
|
||||
|
||||
#include <SDL.h>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
class Application {
|
||||
public:
|
||||
Application();
|
||||
~Application();
|
||||
|
||||
bool init(int argc, char** argv);
|
||||
void run();
|
||||
void shutdown();
|
||||
|
||||
private:
|
||||
void processAudio();
|
||||
void render();
|
||||
void renderControlPanel();
|
||||
void renderSpectrumPanel();
|
||||
void renderWaterfallPanel();
|
||||
void handleSpectrumInput(float posX, float posY, float sizeX, float sizeY);
|
||||
|
||||
void openPortAudio();
|
||||
void openFile(const std::string& path, InputFormat format, double sampleRate);
|
||||
void updateAnalyzerSettings();
|
||||
|
||||
// SDL / GL / ImGui
|
||||
SDL_Window* window_ = nullptr;
|
||||
SDL_GLContext glContext_ = nullptr;
|
||||
bool running_ = false;
|
||||
|
||||
// Audio
|
||||
std::unique_ptr<AudioSource> audioSource_;
|
||||
std::vector<float> audioBuf_; // temp read buffer
|
||||
|
||||
// DSP
|
||||
SpectrumAnalyzer analyzer_;
|
||||
AnalyzerSettings settings_;
|
||||
|
||||
// UI state
|
||||
ColorMap colorMap_;
|
||||
WaterfallDisplay waterfall_;
|
||||
SpectrumDisplay specDisplay_;
|
||||
Cursors cursors_;
|
||||
|
||||
// Display settings
|
||||
float minDB_ = -120.0f;
|
||||
float maxDB_ = 0.0f;
|
||||
FreqScale freqScale_ = FreqScale::Linear;
|
||||
bool paused_ = false;
|
||||
int waterfallW_ = 0;
|
||||
int waterfallH_ = 0;
|
||||
|
||||
// FFT size options
|
||||
static constexpr int kFFTSizes[] = {256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536};
|
||||
static constexpr int kNumFFTSizes = 9;
|
||||
int fftSizeIdx_ = 4; // default 4096
|
||||
|
||||
// Overlap (continuous 0–95%)
|
||||
float overlapPct_ = 50.0f;
|
||||
|
||||
// Window
|
||||
int windowIdx_ = static_cast<int>(WindowType::BlackmanHarris);
|
||||
|
||||
// Color map
|
||||
int colorMapIdx_ = static_cast<int>(ColorMapType::Magma);
|
||||
|
||||
// File playback
|
||||
std::string filePath_;
|
||||
int fileFormatIdx_ = 0;
|
||||
float fileSampleRate_ = 48000.0f;
|
||||
bool fileLoop_ = true;
|
||||
|
||||
// Device selection
|
||||
std::vector<PortAudioSource::DeviceInfo> paDevices_;
|
||||
int paDeviceIdx_ = 0;
|
||||
|
||||
// Channel colors (up to kMaxChannels). Defaults: L=purple, R=green.
|
||||
ImVec4 channelColors_[kMaxChannels] = {
|
||||
{0.70f, 0.30f, 1.00f, 1.0f}, // purple
|
||||
{0.20f, 0.90f, 0.30f, 1.0f}, // green
|
||||
{1.00f, 0.55f, 0.00f, 1.0f}, // orange
|
||||
{0.00f, 0.75f, 1.00f, 1.0f}, // cyan
|
||||
{1.00f, 0.25f, 0.25f, 1.0f}, // red
|
||||
{1.00f, 1.00f, 0.30f, 1.0f}, // yellow
|
||||
{0.50f, 0.80f, 0.50f, 1.0f}, // light green
|
||||
{0.80f, 0.50f, 0.80f, 1.0f}, // pink
|
||||
};
|
||||
int waterfallChannel_ = 0; // which channel drives the waterfall (single mode)
|
||||
bool waterfallMultiCh_ = true; // true = multi-channel overlay mode
|
||||
bool channelEnabled_[kMaxChannels] = {true,true,true,true,true,true,true,true};
|
||||
|
||||
// Spectrum panel geometry (stored for cursor interaction)
|
||||
float specPosX_ = 0, specPosY_ = 0, specSizeX_ = 0, specSizeY_ = 0;
|
||||
};
|
||||
|
||||
} // namespace baudline
|
||||
108
src/ui/ColorMap.cpp
Normal file
108
src/ui/ColorMap.cpp
Normal file
@@ -0,0 +1,108 @@
|
||||
#include "ui/ColorMap.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
// Interpolation helper for colormaps defined as control points.
|
||||
struct ColorStop {
|
||||
float pos;
|
||||
uint8_t r, g, b;
|
||||
};
|
||||
|
||||
static Color3 interpolate(const ColorStop* stops, int count, float t) {
|
||||
t = std::clamp(t, 0.0f, 1.0f);
|
||||
// Find surrounding stops
|
||||
int i = 0;
|
||||
while (i < count - 1 && stops[i + 1].pos < t) ++i;
|
||||
if (i >= count - 1) return {stops[count - 1].r, stops[count - 1].g, stops[count - 1].b};
|
||||
|
||||
float range = stops[i + 1].pos - stops[i].pos;
|
||||
float frac = (range > 0.0f) ? (t - stops[i].pos) / range : 0.0f;
|
||||
|
||||
auto lerp = [](uint8_t a, uint8_t b, float f) -> uint8_t {
|
||||
return static_cast<uint8_t>(a + (b - a) * f);
|
||||
};
|
||||
return {lerp(stops[i].r, stops[i + 1].r, frac),
|
||||
lerp(stops[i].g, stops[i + 1].g, frac),
|
||||
lerp(stops[i].b, stops[i + 1].b, frac)};
|
||||
}
|
||||
|
||||
// ── Colormap definitions (simplified control points) ─────────────────────────
|
||||
|
||||
static const ColorStop kMagma[] = {
|
||||
{0.00f, 0, 0, 4}, {0.13f, 27, 12, 65}, {0.25f, 72, 12, 107},
|
||||
{0.38f, 117, 15, 110}, {0.50f, 159, 42, 99}, {0.63f, 200, 72, 65},
|
||||
{0.75f, 231, 117, 36}, {0.88f, 251, 178, 55}, {1.00f, 252, 253, 191}
|
||||
};
|
||||
|
||||
static const ColorStop kViridis[] = {
|
||||
{0.00f, 68, 1, 84}, {0.13f, 72, 36, 117}, {0.25f, 56, 88, 140},
|
||||
{0.38f, 39, 126, 142}, {0.50f, 31, 161, 135}, {0.63f, 53, 194, 114},
|
||||
{0.75f, 122, 209, 81}, {0.88f, 189, 222, 38}, {1.00f, 253, 231, 37}
|
||||
};
|
||||
|
||||
static const ColorStop kInferno[] = {
|
||||
{0.00f, 0, 0, 4}, {0.13f, 31, 12, 72}, {0.25f, 85, 15, 109},
|
||||
{0.38f, 136, 34, 85}, {0.50f, 186, 54, 55}, {0.63f, 227, 89, 22},
|
||||
{0.75f, 249, 140, 10}, {0.88f, 249, 200, 50}, {1.00f, 252, 255, 164}
|
||||
};
|
||||
|
||||
static const ColorStop kPlasma[] = {
|
||||
{0.00f, 13, 8, 135}, {0.13f, 75, 3, 161}, {0.25f, 125, 3, 168},
|
||||
{0.38f, 168, 34, 150}, {0.50f, 203, 70, 121}, {0.63f, 229, 107, 93},
|
||||
{0.75f, 248, 148, 65}, {0.88f, 253, 195, 40}, {1.00f, 240, 249, 33}
|
||||
};
|
||||
|
||||
ColorMap::ColorMap(ColorMapType type) : type_(type), lut_(256) {
|
||||
buildLUT();
|
||||
}
|
||||
|
||||
void ColorMap::setType(ColorMapType type) {
|
||||
if (type == type_) return;
|
||||
type_ = type;
|
||||
buildLUT();
|
||||
}
|
||||
|
||||
Color3 ColorMap::map(float value) const {
|
||||
int idx = static_cast<int>(std::clamp(value, 0.0f, 1.0f) * 255.0f);
|
||||
return lut_[idx];
|
||||
}
|
||||
|
||||
Color3 ColorMap::mapDB(float dB, float minDB, float maxDB) const {
|
||||
float norm = (dB - minDB) / (maxDB - minDB);
|
||||
return map(std::clamp(norm, 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
void ColorMap::buildLUT() {
|
||||
lut_.resize(256);
|
||||
for (int i = 0; i < 256; ++i) {
|
||||
float t = i / 255.0f;
|
||||
switch (type_) {
|
||||
case ColorMapType::Magma:
|
||||
lut_[i] = interpolate(kMagma, 9, t);
|
||||
break;
|
||||
case ColorMapType::Viridis:
|
||||
lut_[i] = interpolate(kViridis, 9, t);
|
||||
break;
|
||||
case ColorMapType::Inferno:
|
||||
lut_[i] = interpolate(kInferno, 9, t);
|
||||
break;
|
||||
case ColorMapType::Plasma:
|
||||
lut_[i] = interpolate(kPlasma, 9, t);
|
||||
break;
|
||||
case ColorMapType::Grayscale:
|
||||
lut_[i] = {static_cast<uint8_t>(i),
|
||||
static_cast<uint8_t>(i),
|
||||
static_cast<uint8_t>(i)};
|
||||
break;
|
||||
default:
|
||||
lut_[i] = {static_cast<uint8_t>(i),
|
||||
static_cast<uint8_t>(i),
|
||||
static_cast<uint8_t>(i)};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace baudline
|
||||
31
src/ui/ColorMap.h
Normal file
31
src/ui/ColorMap.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/Types.h"
|
||||
#include <vector>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
class ColorMap {
|
||||
public:
|
||||
explicit ColorMap(ColorMapType type = ColorMapType::Magma);
|
||||
|
||||
void setType(ColorMapType type);
|
||||
ColorMapType type() const { return type_; }
|
||||
|
||||
// Map a normalized value [0,1] to RGB.
|
||||
Color3 map(float value) const;
|
||||
|
||||
// Map dB value to RGB given current range.
|
||||
Color3 mapDB(float dB, float minDB, float maxDB) const;
|
||||
|
||||
// Get the full 256-entry LUT.
|
||||
const std::vector<Color3>& lut() const { return lut_; }
|
||||
|
||||
private:
|
||||
void buildLUT();
|
||||
|
||||
ColorMapType type_;
|
||||
std::vector<Color3> lut_; // 256 entries
|
||||
};
|
||||
|
||||
} // namespace baudline
|
||||
176
src/ui/Cursors.cpp
Normal file
176
src/ui/Cursors.cpp
Normal file
@@ -0,0 +1,176 @@
|
||||
#include "ui/Cursors.h"
|
||||
#include <imgui.h>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
static double binToFreqHelper(int bin, double sampleRate, bool isIQ, int fftSize) {
|
||||
if (isIQ) {
|
||||
return -sampleRate / 2.0 + (static_cast<double>(bin) / fftSize) * sampleRate;
|
||||
} else {
|
||||
return (static_cast<double>(bin) / fftSize) * sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
if (cursorB.active && cursorB.bin >= 0 &&
|
||||
cursorB.bin < static_cast<int>(spectrumDB.size())) {
|
||||
cursorB.dB = spectrumDB[cursorB.bin];
|
||||
}
|
||||
}
|
||||
|
||||
void Cursors::draw(const SpectrumDisplay& specDisplay,
|
||||
float posX, float posY, float sizeX, float sizeY,
|
||||
double sampleRate, bool isIQ, FreqScale freqScale,
|
||||
float minDB, float maxDB) const {
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
|
||||
auto drawCursor = [&](const CursorInfo& c, ImU32 color, const char* label) {
|
||||
if (!c.active) return;
|
||||
float x = specDisplay.freqToScreenX(c.freq, posX, sizeX,
|
||||
sampleRate, isIQ, freqScale);
|
||||
float dbNorm = (c.dB - minDB) / (maxDB - minDB);
|
||||
dbNorm = std::clamp(dbNorm, 0.0f, 1.0f);
|
||||
float y = posY + sizeY * (1.0f - dbNorm);
|
||||
|
||||
// Vertical line
|
||||
dl->AddLine({x, posY}, {x, posY + sizeY}, color, 1.0f);
|
||||
// Horizontal line
|
||||
dl->AddLine({posX, y}, {posX + sizeX, y}, color & 0x80FFFFFF, 1.0f);
|
||||
// Crosshair
|
||||
dl->AddCircle({x, y}, 5.0f, color, 12, 2.0f);
|
||||
|
||||
// Label
|
||||
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);
|
||||
else if (std::abs(c.freq) >= 1e3)
|
||||
std::snprintf(buf, sizeof(buf), "%s: %.3f kHz %.1f dB",
|
||||
label, c.freq / 1e3, c.dB);
|
||||
else
|
||||
std::snprintf(buf, sizeof(buf), "%s: %.1f Hz %.1f dB",
|
||||
label, c.freq, c.dB);
|
||||
|
||||
ImVec2 textSize = ImGui::CalcTextSize(buf);
|
||||
float tx = std::min(x + 8, posX + sizeX - textSize.x - 4);
|
||||
float ty = std::max(y - 18, posY + 2);
|
||||
dl->AddRectFilled({tx - 2, ty - 1}, {tx + textSize.x + 2, ty + textSize.y + 1},
|
||||
IM_COL32(0, 0, 0, 180));
|
||||
dl->AddText({tx, ty}, color, buf);
|
||||
};
|
||||
|
||||
drawCursor(cursorA, IM_COL32(255, 255, 0, 220), "A");
|
||||
drawCursor(cursorB, IM_COL32(0, 200, 255, 220), "B");
|
||||
|
||||
// Delta display
|
||||
if (showDelta && cursorA.active && cursorB.active) {
|
||||
double dFreq = cursorB.freq - cursorA.freq;
|
||||
float dDB = cursorB.dB - cursorA.dB;
|
||||
char buf[128];
|
||||
if (std::abs(dFreq) >= 1e6)
|
||||
std::snprintf(buf, sizeof(buf), "dF=%.6f MHz dA=%.1f dB",
|
||||
dFreq / 1e6, dDB);
|
||||
else if (std::abs(dFreq) >= 1e3)
|
||||
std::snprintf(buf, sizeof(buf), "dF=%.3f kHz dA=%.1f dB",
|
||||
dFreq / 1e3, dDB);
|
||||
else
|
||||
std::snprintf(buf, sizeof(buf), "dF=%.1f Hz dA=%.1f dB",
|
||||
dFreq, dDB);
|
||||
|
||||
ImVec2 textSize = ImGui::CalcTextSize(buf);
|
||||
float tx = posX + sizeX - textSize.x - 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);
|
||||
}
|
||||
|
||||
// Hover cursor
|
||||
if (hover.active) {
|
||||
float x = specDisplay.freqToScreenX(hover.freq, posX, sizeX,
|
||||
sampleRate, isIQ, freqScale);
|
||||
dl->AddLine({x, posY}, {x, posY + sizeY}, IM_COL32(200, 200, 200, 80), 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
void Cursors::drawPanel() const {
|
||||
ImGui::Text("Cursors:");
|
||||
ImGui::Separator();
|
||||
|
||||
auto showCursor = [](const char* label, const CursorInfo& c) {
|
||||
if (!c.active) {
|
||||
ImGui::Text("%s: (inactive)", label);
|
||||
return;
|
||||
}
|
||||
if (std::abs(c.freq) >= 1e6)
|
||||
ImGui::Text("%s: %.6f MHz, %.1f dB", label, c.freq / 1e6, c.dB);
|
||||
else if (std::abs(c.freq) >= 1e3)
|
||||
ImGui::Text("%s: %.3f kHz, %.1f dB", label, c.freq / 1e3, c.dB);
|
||||
else
|
||||
ImGui::Text("%s: %.1f Hz, %.1f dB", label, c.freq, c.dB);
|
||||
};
|
||||
|
||||
showCursor("A", cursorA);
|
||||
showCursor("B", cursorB);
|
||||
|
||||
if (cursorA.active && cursorB.active) {
|
||||
double dF = cursorB.freq - cursorA.freq;
|
||||
float dA = cursorB.dB - cursorA.dB;
|
||||
ImGui::Separator();
|
||||
if (std::abs(dF) >= 1e6)
|
||||
ImGui::Text("Delta: %.6f MHz, %.1f dB", dF / 1e6, dA);
|
||||
else if (std::abs(dF) >= 1e3)
|
||||
ImGui::Text("Delta: %.3f kHz, %.1f dB", dF / 1e3, dA);
|
||||
else
|
||||
ImGui::Text("Delta: %.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);
|
||||
else if (std::abs(hover.freq) >= 1e3)
|
||||
ImGui::Text("Hover: %.3f kHz, %.1f dB", hover.freq / 1e3, hover.dB);
|
||||
else
|
||||
ImGui::Text("Hover: %.1f Hz, %.1f dB", hover.freq, hover.dB);
|
||||
}
|
||||
}
|
||||
|
||||
void Cursors::setCursorA(double freq, float dB, int bin) {
|
||||
cursorA = {true, freq, dB, bin};
|
||||
}
|
||||
|
||||
void Cursors::setCursorB(double freq, float dB, int bin) {
|
||||
cursorB = {true, freq, dB, bin};
|
||||
}
|
||||
|
||||
void Cursors::snapToPeak(const std::vector<float>& spectrumDB,
|
||||
double sampleRate, bool isIQ, int fftSize) {
|
||||
if (spectrumDB.empty()) return;
|
||||
auto it = std::max_element(spectrumDB.begin(), spectrumDB.end());
|
||||
int bin = static_cast<int>(std::distance(spectrumDB.begin(), it));
|
||||
double freq = binToFreqHelper(bin, sampleRate, isIQ, fftSize);
|
||||
setCursorA(freq, *it, bin);
|
||||
}
|
||||
|
||||
int Cursors::findLocalPeak(const std::vector<float>& spectrumDB,
|
||||
int centerBin, int window) const {
|
||||
int bins = static_cast<int>(spectrumDB.size());
|
||||
int lo = std::max(0, centerBin - window);
|
||||
int hi = std::min(bins - 1, centerBin + window);
|
||||
int best = lo;
|
||||
for (int i = lo + 1; i <= hi; ++i) {
|
||||
if (spectrumDB[i] > spectrumDB[best]) best = i;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
} // namespace baudline
|
||||
51
src/ui/Cursors.h
Normal file
51
src/ui/Cursors.h
Normal file
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/Types.h"
|
||||
#include "ui/SpectrumDisplay.h"
|
||||
#include <vector>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
struct CursorInfo {
|
||||
bool active = false;
|
||||
double freq = 0.0; // Hz
|
||||
float dB = -200.0f;
|
||||
int bin = 0;
|
||||
};
|
||||
|
||||
class Cursors {
|
||||
public:
|
||||
// Update cursor positions from mouse input and spectrum data.
|
||||
void update(const std::vector<float>& spectrumDB,
|
||||
double sampleRate, bool isIQ, int fftSize);
|
||||
|
||||
// Draw cursor overlays on the spectrum display area.
|
||||
void draw(const SpectrumDisplay& specDisplay,
|
||||
float posX, float posY, float sizeX, float sizeY,
|
||||
double sampleRate, bool isIQ, FreqScale freqScale,
|
||||
float minDB, float maxDB) const;
|
||||
|
||||
// Draw cursor readout panel (ImGui widgets).
|
||||
void drawPanel() const;
|
||||
|
||||
// Set cursor A/B positions from mouse click.
|
||||
void setCursorA(double freq, float dB, int bin);
|
||||
void setCursorB(double freq, float dB, int bin);
|
||||
|
||||
// Auto-find peak and set cursor to it.
|
||||
void snapToPeak(const std::vector<float>& spectrumDB,
|
||||
double sampleRate, bool isIQ, int fftSize);
|
||||
|
||||
// Find peak near a given bin (within a window).
|
||||
int findLocalPeak(const std::vector<float>& spectrumDB,
|
||||
int centerBin, int window = 20) const;
|
||||
|
||||
CursorInfo cursorA;
|
||||
CursorInfo cursorB;
|
||||
bool showDelta = true;
|
||||
|
||||
// Hover cursor (follows mouse, always active)
|
||||
CursorInfo hover;
|
||||
};
|
||||
|
||||
} // namespace baudline
|
||||
212
src/ui/SpectrumDisplay.cpp
Normal file
212
src/ui/SpectrumDisplay.cpp
Normal file
@@ -0,0 +1,212 @@
|
||||
#include "ui/SpectrumDisplay.h"
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
static float freqToLogFrac(double freq, double minFreq, double maxFreq) {
|
||||
if (freq <= 0 || minFreq <= 0) return 0.0f;
|
||||
double logMin = std::log10(minFreq);
|
||||
double logMax = std::log10(maxFreq);
|
||||
double logF = std::log10(freq);
|
||||
return static_cast<float>((logF - logMin) / (logMax - logMin));
|
||||
}
|
||||
|
||||
// Build a decimated polyline for one spectrum.
|
||||
static void buildPolyline(const std::vector<float>& spectrumDB,
|
||||
float minDB, float maxDB,
|
||||
double freqMin, double freqMax,
|
||||
bool isIQ, FreqScale freqScale,
|
||||
float posX, float posY, float sizeX, float sizeY,
|
||||
std::vector<ImVec2>& outPoints) {
|
||||
int bins = static_cast<int>(spectrumDB.size());
|
||||
int displayPts = std::min(bins, static_cast<int>(sizeX));
|
||||
if (displayPts < 2) displayPts = 2;
|
||||
|
||||
outPoints.resize(displayPts);
|
||||
for (int idx = 0; idx < displayPts; ++idx) {
|
||||
float frac = static_cast<float>(idx) / (displayPts - 1);
|
||||
float xFrac;
|
||||
|
||||
if (freqScale == FreqScale::Logarithmic && !isIQ) {
|
||||
double freq = frac * (freqMax - freqMin) + freqMin;
|
||||
double logMin = std::max(freqMin, 1.0);
|
||||
xFrac = freqToLogFrac(freq, logMin, freqMax);
|
||||
} else {
|
||||
xFrac = frac;
|
||||
}
|
||||
|
||||
// Bucket range for peak-hold decimation.
|
||||
float binF = frac * (bins - 1);
|
||||
float binPrev = (idx > 0)
|
||||
? static_cast<float>(idx - 1) / (displayPts - 1) * (bins - 1)
|
||||
: binF;
|
||||
float binNext = (idx < displayPts - 1)
|
||||
? static_cast<float>(idx + 1) / (displayPts - 1) * (bins - 1)
|
||||
: binF;
|
||||
int b0 = static_cast<int>((binPrev + binF) * 0.5f);
|
||||
int b1 = static_cast<int>((binF + binNext) * 0.5f);
|
||||
b0 = std::clamp(b0, 0, bins - 1);
|
||||
b1 = std::clamp(b1, b0, bins - 1);
|
||||
|
||||
float peakDB = spectrumDB[b0];
|
||||
for (int b = b0 + 1; b <= b1; ++b)
|
||||
peakDB = std::max(peakDB, spectrumDB[b]);
|
||||
|
||||
float x = posX + xFrac * sizeX;
|
||||
float dbNorm = std::clamp((peakDB - minDB) / (maxDB - minDB), 0.0f, 1.0f);
|
||||
float y = posY + sizeY * (1.0f - dbNorm);
|
||||
outPoints[idx] = {x, y};
|
||||
}
|
||||
}
|
||||
|
||||
void SpectrumDisplay::draw(const std::vector<std::vector<float>>& spectra,
|
||||
const std::vector<ChannelStyle>& styles,
|
||||
float minDB, float maxDB,
|
||||
double sampleRate, bool isIQ,
|
||||
FreqScale freqScale,
|
||||
float posX, float posY,
|
||||
float sizeX, float sizeY) const {
|
||||
if (spectra.empty() || spectra[0].empty() || sizeX <= 0 || sizeY <= 0) return;
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
double freqMin = isIQ ? -sampleRate / 2.0 : 0.0;
|
||||
double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0;
|
||||
|
||||
// Background
|
||||
dl->AddRectFilled({posX, posY}, {posX + sizeX, posY + sizeY},
|
||||
IM_COL32(20, 20, 30, 255));
|
||||
|
||||
// Grid lines
|
||||
if (showGrid) {
|
||||
ImU32 gridCol = IM_COL32(60, 60, 80, 128);
|
||||
ImU32 textCol = IM_COL32(180, 180, 200, 200);
|
||||
|
||||
float dbStep = 10.0f;
|
||||
for (float db = std::ceil(minDB / dbStep) * dbStep; db <= maxDB; db += dbStep) {
|
||||
float y = posY + sizeY * (1.0f - (db - minDB) / (maxDB - minDB));
|
||||
dl->AddLine({posX, y}, {posX + sizeX, y}, gridCol);
|
||||
char label[32];
|
||||
std::snprintf(label, sizeof(label), "%.0f dB", db);
|
||||
dl->AddText({posX + 2, y - 12}, textCol, label);
|
||||
}
|
||||
|
||||
int numVLines = 8;
|
||||
for (int i = 0; i <= numVLines; ++i) {
|
||||
float frac = static_cast<float>(i) / numVLines;
|
||||
double freq;
|
||||
float screenFrac;
|
||||
|
||||
if (freqScale == FreqScale::Linear) {
|
||||
freq = freqMin + frac * (freqMax - freqMin);
|
||||
screenFrac = frac;
|
||||
} else {
|
||||
double logMinF = std::max(freqMin, 1.0);
|
||||
double logMaxF = freqMax;
|
||||
freq = std::pow(10.0, std::log10(logMinF) +
|
||||
frac * (std::log10(logMaxF) - std::log10(logMinF)));
|
||||
screenFrac = frac;
|
||||
}
|
||||
|
||||
float x = posX + screenFrac * sizeX;
|
||||
dl->AddLine({x, posY}, {x, posY + sizeY}, gridCol);
|
||||
|
||||
char label[32];
|
||||
if (std::abs(freq) >= 1e6)
|
||||
std::snprintf(label, sizeof(label), "%.2f MHz", freq / 1e6);
|
||||
else if (std::abs(freq) >= 1e3)
|
||||
std::snprintf(label, sizeof(label), "%.1f kHz", freq / 1e3);
|
||||
else
|
||||
std::snprintf(label, sizeof(label), "%.0f Hz", freq);
|
||||
dl->AddText({x + 2, posY + sizeY - 14}, textCol, label);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw each channel's spectrum.
|
||||
std::vector<ImVec2> points;
|
||||
int nCh = static_cast<int>(spectra.size());
|
||||
for (int ch = 0; ch < nCh; ++ch) {
|
||||
if (spectra[ch].empty()) continue;
|
||||
const ChannelStyle& st = (ch < static_cast<int>(styles.size()))
|
||||
? styles[ch]
|
||||
: styles.back();
|
||||
|
||||
buildPolyline(spectra[ch], minDB, maxDB, freqMin, freqMax,
|
||||
isIQ, freqScale, posX, posY, sizeX, sizeY, points);
|
||||
|
||||
// Fill
|
||||
if (fillSpectrum && points.size() >= 2) {
|
||||
for (size_t i = 0; i + 1 < points.size(); ++i) {
|
||||
ImVec2 tl = points[i];
|
||||
ImVec2 tr = points[i + 1];
|
||||
ImVec2 bl = {tl.x, posY + sizeY};
|
||||
ImVec2 br = {tr.x, posY + sizeY};
|
||||
dl->AddQuadFilled(tl, tr, br, bl, st.fillColor);
|
||||
}
|
||||
}
|
||||
|
||||
// Line
|
||||
if (points.size() >= 2)
|
||||
dl->AddPolyline(points.data(), static_cast<int>(points.size()),
|
||||
st.lineColor, ImDrawFlags_None, 1.5f);
|
||||
}
|
||||
|
||||
// Border
|
||||
dl->AddRect({posX, posY}, {posX + sizeX, posY + sizeY},
|
||||
IM_COL32(100, 100, 120, 200));
|
||||
}
|
||||
|
||||
// Single-channel convenience wrapper.
|
||||
void SpectrumDisplay::draw(const std::vector<float>& spectrumDB,
|
||||
float minDB, float maxDB,
|
||||
double sampleRate, bool isIQ,
|
||||
FreqScale freqScale,
|
||||
float posX, float posY,
|
||||
float sizeX, float sizeY) const {
|
||||
std::vector<std::vector<float>> spectra = {spectrumDB};
|
||||
std::vector<ChannelStyle> styles = {{IM_COL32(0, 255, 128, 255),
|
||||
IM_COL32(0, 255, 128, 40)}};
|
||||
draw(spectra, styles, minDB, maxDB, sampleRate, isIQ, freqScale,
|
||||
posX, posY, sizeX, sizeY);
|
||||
}
|
||||
|
||||
double SpectrumDisplay::screenXToFreq(float screenX, float posX, float sizeX,
|
||||
double sampleRate, bool isIQ,
|
||||
FreqScale freqScale) const {
|
||||
float frac = (screenX - posX) / sizeX;
|
||||
frac = std::clamp(frac, 0.0f, 1.0f);
|
||||
|
||||
double freqMin = isIQ ? -sampleRate / 2.0 : 0.0;
|
||||
double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0;
|
||||
|
||||
if (freqScale == FreqScale::Logarithmic && !isIQ) {
|
||||
double logMin = std::log10(std::max(freqMin, 1.0));
|
||||
double logMax = std::log10(freqMax);
|
||||
return std::pow(10.0, logMin + frac * (logMax - logMin));
|
||||
}
|
||||
return freqMin + frac * (freqMax - freqMin);
|
||||
}
|
||||
|
||||
float SpectrumDisplay::freqToScreenX(double freq, float posX, float sizeX,
|
||||
double sampleRate, bool isIQ,
|
||||
FreqScale freqScale) const {
|
||||
double freqMin = isIQ ? -sampleRate / 2.0 : 0.0;
|
||||
double freqMax = isIQ ? sampleRate / 2.0 : sampleRate / 2.0;
|
||||
|
||||
float frac;
|
||||
if (freqScale == FreqScale::Logarithmic && !isIQ) {
|
||||
frac = freqToLogFrac(freq, std::max(freqMin, 1.0), freqMax);
|
||||
} else {
|
||||
frac = static_cast<float>((freq - freqMin) / (freqMax - freqMin));
|
||||
}
|
||||
return posX + frac * sizeX;
|
||||
}
|
||||
|
||||
float SpectrumDisplay::screenYToDB(float screenY, float posY, float sizeY,
|
||||
float minDB, float maxDB) const {
|
||||
float frac = 1.0f - (screenY - posY) / sizeY;
|
||||
frac = std::clamp(frac, 0.0f, 1.0f);
|
||||
return minDB + frac * (maxDB - minDB);
|
||||
}
|
||||
|
||||
} // namespace baudline
|
||||
43
src/ui/SpectrumDisplay.h
Normal file
43
src/ui/SpectrumDisplay.h
Normal file
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/Types.h"
|
||||
#include <imgui.h>
|
||||
#include <vector>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
struct ChannelStyle {
|
||||
ImU32 lineColor;
|
||||
ImU32 fillColor;
|
||||
};
|
||||
|
||||
class SpectrumDisplay {
|
||||
public:
|
||||
// Draw multiple channel spectra overlaid.
|
||||
// `spectra` has one entry per channel; `styles` has matching colors.
|
||||
void draw(const std::vector<std::vector<float>>& spectra,
|
||||
const std::vector<ChannelStyle>& styles,
|
||||
float minDB, float maxDB,
|
||||
double sampleRate, bool isIQ,
|
||||
FreqScale freqScale,
|
||||
float posX, float posY, float sizeX, float sizeY) const;
|
||||
|
||||
// Convenience: single-channel draw (backward compat).
|
||||
void draw(const std::vector<float>& spectrumDB,
|
||||
float minDB, float maxDB,
|
||||
double sampleRate, bool isIQ,
|
||||
FreqScale freqScale,
|
||||
float posX, float posY, float sizeX, float sizeY) const;
|
||||
|
||||
double screenXToFreq(float screenX, float posX, float sizeX,
|
||||
double sampleRate, bool isIQ, FreqScale freqScale) const;
|
||||
float freqToScreenX(double freq, float posX, float sizeX,
|
||||
double sampleRate, bool isIQ, FreqScale freqScale) const;
|
||||
float screenYToDB(float screenY, float posY, float sizeY,
|
||||
float minDB, float maxDB) const;
|
||||
|
||||
bool showGrid = true;
|
||||
bool fillSpectrum = false;
|
||||
};
|
||||
|
||||
} // namespace baudline
|
||||
128
src/ui/WaterfallDisplay.cpp
Normal file
128
src/ui/WaterfallDisplay.cpp
Normal file
@@ -0,0 +1,128 @@
|
||||
#include "ui/WaterfallDisplay.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
WaterfallDisplay::WaterfallDisplay() = default;
|
||||
|
||||
WaterfallDisplay::~WaterfallDisplay() {
|
||||
if (texture_) glDeleteTextures(1, &texture_);
|
||||
}
|
||||
|
||||
void WaterfallDisplay::init(int width, int height) {
|
||||
width_ = width;
|
||||
height_ = height;
|
||||
currentRow_ = height_ - 1;
|
||||
|
||||
pixelBuf_.resize(width_ * height_ * 3, 0);
|
||||
|
||||
if (texture_) glDeleteTextures(1, &texture_);
|
||||
glGenTextures(1, &texture_);
|
||||
glBindTexture(GL_TEXTURE_2D, texture_);
|
||||
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());
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
}
|
||||
|
||||
void WaterfallDisplay::resize(int width, int height) {
|
||||
if (width == width_ && height == height_) return;
|
||||
init(width, height);
|
||||
}
|
||||
|
||||
float WaterfallDisplay::sampleBin(const std::vector<float>& spec, float binF) {
|
||||
int bins = static_cast<int>(spec.size());
|
||||
int b0 = static_cast<int>(binF);
|
||||
int b1 = std::min(b0 + 1, bins - 1);
|
||||
float t = binF - b0;
|
||||
return spec[b0] * (1.0f - t) + spec[b1] * t;
|
||||
}
|
||||
|
||||
void WaterfallDisplay::advanceRow() {
|
||||
currentRow_ = (currentRow_ - 1 + height_) % height_;
|
||||
}
|
||||
|
||||
// ── Single-channel (colormap) mode ───────────────────────────────────────────
|
||||
|
||||
void WaterfallDisplay::pushLine(const std::vector<float>& spectrumDB,
|
||||
float minDB, float maxDB) {
|
||||
if (width_ == 0 || height_ == 0) return;
|
||||
|
||||
int bins = static_cast<int>(spectrumDB.size());
|
||||
int row = currentRow_;
|
||||
int rowOffset = row * width_ * 3;
|
||||
|
||||
for (int x = 0; x < width_; ++x) {
|
||||
float frac = static_cast<float>(x) / (width_ - 1);
|
||||
float dB = sampleBin(spectrumDB, frac * (bins - 1));
|
||||
Color3 c = colorMap_.mapDB(dB, minDB, maxDB);
|
||||
|
||||
pixelBuf_[rowOffset + x * 3 + 0] = c.r;
|
||||
pixelBuf_[rowOffset + x * 3 + 1] = c.g;
|
||||
pixelBuf_[rowOffset + x * 3 + 2] = c.b;
|
||||
}
|
||||
|
||||
uploadRow(row);
|
||||
advanceRow();
|
||||
}
|
||||
|
||||
// ── Multi-channel overlay mode ───────────────────────────────────────────────
|
||||
|
||||
void WaterfallDisplay::pushLineMulti(
|
||||
const std::vector<std::vector<float>>& channelSpectra,
|
||||
const std::vector<WaterfallChannelInfo>& channels,
|
||||
float minDB, float maxDB) {
|
||||
if (width_ == 0 || height_ == 0) return;
|
||||
|
||||
int nCh = static_cast<int>(channelSpectra.size());
|
||||
int row = currentRow_;
|
||||
int rowOffset = row * width_ * 3;
|
||||
float range = maxDB - minDB;
|
||||
if (range < 1.0f) range = 1.0f;
|
||||
|
||||
for (int x = 0; x < width_; ++x) {
|
||||
float frac = static_cast<float>(x) / (width_ - 1);
|
||||
|
||||
// Accumulate color contributions from each enabled channel.
|
||||
float accR = 0.0f, accG = 0.0f, accB = 0.0f;
|
||||
|
||||
for (int ch = 0; ch < nCh; ++ch) {
|
||||
if (ch >= static_cast<int>(channels.size()) || !channels[ch].enabled)
|
||||
continue;
|
||||
if (channelSpectra[ch].empty()) continue;
|
||||
|
||||
int bins = static_cast<int>(channelSpectra[ch].size());
|
||||
float dB = sampleBin(channelSpectra[ch], frac * (bins - 1));
|
||||
float intensity = std::clamp((dB - minDB) / range, 0.0f, 1.0f);
|
||||
|
||||
accR += channels[ch].r * intensity;
|
||||
accG += channels[ch].g * intensity;
|
||||
accB += channels[ch].b * intensity;
|
||||
}
|
||||
|
||||
pixelBuf_[rowOffset + x * 3 + 0] =
|
||||
static_cast<uint8_t>(std::min(accR, 1.0f) * 255.0f);
|
||||
pixelBuf_[rowOffset + x * 3 + 1] =
|
||||
static_cast<uint8_t>(std::min(accG, 1.0f) * 255.0f);
|
||||
pixelBuf_[rowOffset + x * 3 + 2] =
|
||||
static_cast<uint8_t>(std::min(accB, 1.0f) * 255.0f);
|
||||
}
|
||||
|
||||
uploadRow(row);
|
||||
advanceRow();
|
||||
}
|
||||
|
||||
void WaterfallDisplay::uploadRow(int row) {
|
||||
glBindTexture(GL_TEXTURE_2D, texture_);
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, row, width_, 1,
|
||||
GL_RGB, GL_UNSIGNED_BYTE,
|
||||
pixelBuf_.data() + row * width_ * 3);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
}
|
||||
|
||||
} // namespace baudline
|
||||
64
src/ui/WaterfallDisplay.h
Normal file
64
src/ui/WaterfallDisplay.h
Normal file
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/Types.h"
|
||||
#include "ui/ColorMap.h"
|
||||
#include <GL/gl.h>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
|
||||
namespace baudline {
|
||||
|
||||
// Per-channel color + enable flag for multi-channel waterfall mode.
|
||||
struct WaterfallChannelInfo {
|
||||
float r, g, b; // channel color [0,1]
|
||||
bool enabled;
|
||||
};
|
||||
|
||||
class WaterfallDisplay {
|
||||
public:
|
||||
WaterfallDisplay();
|
||||
~WaterfallDisplay();
|
||||
|
||||
// Initialize OpenGL texture. Call after GL context is ready.
|
||||
void init(int width, int height);
|
||||
|
||||
// Single-channel mode: colormap-based.
|
||||
void pushLine(const std::vector<float>& spectrumDB, float minDB, float maxDB);
|
||||
|
||||
// Multi-channel overlay mode: each channel is rendered in its own color,
|
||||
// intensity proportional to signal level. Colors are additively blended.
|
||||
void pushLineMulti(const std::vector<std::vector<float>>& channelSpectra,
|
||||
const std::vector<WaterfallChannelInfo>& channels,
|
||||
float minDB, float maxDB);
|
||||
|
||||
GLuint textureID() const { return texture_; }
|
||||
int width() const { return width_; }
|
||||
int height() const { return height_; }
|
||||
int currentRow() const { return currentRow_; }
|
||||
|
||||
void resize(int width, int height);
|
||||
|
||||
void setColorMap(const ColorMap& cm) { colorMap_ = cm; }
|
||||
|
||||
float zoomX = 1.0f;
|
||||
float zoomY = 1.0f;
|
||||
float scrollX = 0.0f;
|
||||
float scrollY = 0.0f;
|
||||
|
||||
private:
|
||||
void uploadRow(int row);
|
||||
void advanceRow();
|
||||
|
||||
// Interpolate a dB value at a fractional bin position.
|
||||
static float sampleBin(const std::vector<float>& spec, float binF);
|
||||
|
||||
GLuint texture_ = 0;
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
int currentRow_ = 0;
|
||||
|
||||
ColorMap colorMap_;
|
||||
std::vector<uint8_t> pixelBuf_;
|
||||
};
|
||||
|
||||
} // namespace baudline
|
||||
Reference in New Issue
Block a user