Compare commits

...

4 Commits

Author SHA1 Message Date
3b4f507df9 optional additive blending for the spectrogram 2026-03-29 03:04:12 +02:00
baf6fd94cc some more refactoring
- UIState.h: Replaced C-style arrays bool[] and ImVec4[] with
std::array<bool, kMaxChannels> and std::array<ImVec4, kMaxChannels> for
type safety and STL compatibility.
  - Application.cpp: Extracted duplicated DPI calculation (lines 189-196
and 475-481) into a systemDpiScale() helper method, reducing both call
sites to single-line calls.
  - Application.cpp: Replaced magic 1400, 900 window dimensions with
kDefaultWindowWidth/kDefaultWindowHeight constants (defined in Types.h).
  - AudioEngine.cpp: Named the magic epsilon thresholds 1e-20f →
kLinearEpsilon and 1e-30f → kLogGuard with explanatory comments, defined
in an anonymous namespace.
  - Types.h: Added kDefaultWindowWidth and kDefaultWindowHeight
constants.
2026-03-28 16:09:29 +01:00
b42d7fb69b split off more stuff 2026-03-28 15:40:16 +01:00
b5906d2ab1 split off AudioEngine 2026-03-28 15:39:47 +01:00
14 changed files with 1867 additions and 1687 deletions

View File

@@ -47,11 +47,14 @@ set(SOURCES
src/dsp/SpectrumAnalyzer.cpp src/dsp/SpectrumAnalyzer.cpp
src/audio/MiniAudioSource.cpp src/audio/MiniAudioSource.cpp
src/audio/FileSource.cpp src/audio/FileSource.cpp
src/audio/AudioEngine.cpp
src/ui/ColorMap.cpp src/ui/ColorMap.cpp
src/ui/WaterfallDisplay.cpp src/ui/WaterfallDisplay.cpp
src/ui/SpectrumDisplay.cpp src/ui/SpectrumDisplay.cpp
src/ui/Cursors.cpp src/ui/Cursors.cpp
src/ui/Measurements.cpp src/ui/Measurements.cpp
src/ui/ControlPanel.cpp
src/ui/DisplayPanel.cpp
src/ui/Application.cpp src/ui/Application.cpp
) )

345
src/audio/AudioEngine.cpp Normal file
View File

@@ -0,0 +1,345 @@
#include "audio/AudioEngine.h"
#include "audio/FileSource.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
namespace baudmine {
namespace {
constexpr float kLinearEpsilon = 1e-20f; // threshold for log10 of linear power
constexpr float kLogGuard = 1e-30f; // guard against log10(0) in compressed scale
} // namespace
AudioEngine::AudioEngine() = default;
// ── Device enumeration ───────────────────────────────────────────────────────
void AudioEngine::enumerateDevices() {
devices_ = MiniAudioSource::listInputDevices();
}
void AudioEngine::clearDeviceSelections() {
std::memset(deviceSelected_, 0, sizeof(deviceSelected_));
}
// ── Source management ────────────────────────────────────────────────────────
void AudioEngine::closeAll() {
if (audioSource_) audioSource_->close();
audioSource_.reset();
extraDevices_.clear();
}
void AudioEngine::openDevice(int deviceListIdx) {
closeAll();
int deviceIdx = -1;
double sr = 48000.0;
if (deviceListIdx >= 0 && deviceListIdx < static_cast<int>(devices_.size())) {
deviceIdx = devices_[deviceListIdx].index;
sr = devices_[deviceListIdx].defaultSampleRate;
}
int reqCh = 2;
if (deviceListIdx >= 0 && deviceListIdx < static_cast<int>(devices_.size()))
reqCh = std::min(devices_[deviceListIdx].maxInputChannels, kMaxChannels);
if (reqCh < 1) reqCh = 1;
auto src = std::make_unique<MiniAudioSource>(sr, reqCh, deviceIdx);
if (src->open()) {
audioSource_ = std::move(src);
settings_.sampleRate = audioSource_->sampleRate();
settings_.isIQ = false;
settings_.numChannels = audioSource_->channels();
} else {
std::fprintf(stderr, "Failed to open audio device\n");
}
}
void AudioEngine::openMultiDevice(const bool selected[], int maxDevs) {
closeAll();
std::vector<int> sel;
for (int i = 0; i < maxDevs; ++i)
if (selected[i]) sel.push_back(i);
if (sel.empty()) return;
// First selected device becomes the primary source.
{
int idx = sel[0];
double sr = devices_[idx].defaultSampleRate;
int reqCh = std::min(devices_[idx].maxInputChannels, kMaxChannels);
if (reqCh < 1) reqCh = 1;
auto src = std::make_unique<MiniAudioSource>(sr, reqCh, devices_[idx].index);
if (src->open()) {
audioSource_ = std::move(src);
settings_.sampleRate = audioSource_->sampleRate();
settings_.isIQ = false;
settings_.numChannels = audioSource_->channels();
} else {
std::fprintf(stderr, "Failed to open primary device %s\n",
devices_[idx].name.c_str());
return;
}
}
// Remaining selected devices become extra sources.
int totalCh = settings_.numChannels;
for (size_t s = 1; s < sel.size() && totalCh < kMaxChannels; ++s) {
int idx = sel[s];
double sr = devices_[idx].defaultSampleRate;
int reqCh = std::min(devices_[idx].maxInputChannels, kMaxChannels - totalCh);
if (reqCh < 1) reqCh = 1;
auto src = std::make_unique<MiniAudioSource>(sr, reqCh, devices_[idx].index);
if (src->open()) {
auto ed = std::make_unique<ExtraDevice>();
ed->source = std::move(src);
AnalyzerSettings es = settings_;
es.sampleRate = ed->source->sampleRate();
es.numChannels = ed->source->channels();
es.isIQ = false;
ed->analyzer.configure(es);
totalCh += ed->source->channels();
extraDevices_.push_back(std::move(ed));
} else {
std::fprintf(stderr, "Failed to open extra device %s\n",
devices_[idx].name.c_str());
}
}
}
void AudioEngine::openFile(const std::string& path, InputFormat format,
double sampleRate, bool loop) {
closeAll();
bool isIQ = (format != InputFormat::WAV);
auto src = std::make_unique<FileSource>(path, format, sampleRate, loop);
if (src->open()) {
settings_.sampleRate = src->sampleRate();
settings_.isIQ = isIQ;
settings_.numChannels = isIQ ? 1 : src->channels();
audioSource_ = std::move(src);
} else {
std::fprintf(stderr, "Failed to open file: %s\n", path.c_str());
}
}
// ── Analyzer ─────────────────────────────────────────────────────────────────
void AudioEngine::configure(const AnalyzerSettings& s) {
settings_ = s;
analyzer_.configure(settings_);
for (auto& ed : extraDevices_) {
AnalyzerSettings es = settings_;
es.sampleRate = ed->source->sampleRate();
es.numChannels = ed->source->channels();
es.isIQ = false;
ed->analyzer.configure(es);
}
}
void AudioEngine::clearHistory() {
analyzer_.clearHistory();
for (auto& ed : extraDevices_)
ed->analyzer.clearHistory();
}
int AudioEngine::processAudio() {
if (!audioSource_) return 0;
int channels = audioSource_->channels();
size_t hopFrames = static_cast<size_t>(
settings_.fftSize * (1.0f - settings_.overlap));
if (hopFrames < 1) hopFrames = 1;
audioBuf_.resize(hopFrames * channels);
constexpr int kMaxSpectraPerFrame = 8;
// Process primary source.
int spectraThisFrame = 0;
while (spectraThisFrame < kMaxSpectraPerFrame) {
size_t framesRead = audioSource_->read(audioBuf_.data(), hopFrames);
if (framesRead == 0) break;
analyzer_.pushSamples(audioBuf_.data(), framesRead);
if (analyzer_.hasNewSpectrum())
++spectraThisFrame;
}
// Process extra devices independently.
for (auto& ed : extraDevices_) {
int edCh = ed->source->channels();
const auto& edSettings = ed->analyzer.settings();
size_t edHop = static_cast<size_t>(edSettings.fftSize * (1.0f - edSettings.overlap));
if (edHop < 1) edHop = 1;
ed->audioBuf.resize(edHop * edCh);
int edSpectra = 0;
while (edSpectra < kMaxSpectraPerFrame) {
size_t framesRead = ed->source->read(ed->audioBuf.data(), edHop);
if (framesRead == 0) break;
ed->analyzer.pushSamples(ed->audioBuf.data(), framesRead);
if (ed->analyzer.hasNewSpectrum())
++edSpectra;
}
}
return spectraThisFrame;
}
void AudioEngine::drainSources() {
if (audioSource_ && audioSource_->isRealTime()) {
int channels = audioSource_->channels();
std::vector<float> drain(4096 * channels);
while (audioSource_->read(drain.data(), 4096) > 0) {}
}
for (auto& ed : extraDevices_) {
if (ed->source && ed->source->isRealTime()) {
int ch = ed->source->channels();
std::vector<float> drain(4096 * ch);
while (ed->source->read(drain.data(), 4096) > 0) {}
}
}
}
// ── Unified channel view ─────────────────────────────────────────────────────
int AudioEngine::totalNumSpectra() const {
int n = analyzer_.numSpectra();
for (auto& ed : extraDevices_)
n += ed->analyzer.numSpectra();
return n;
}
const std::vector<float>& AudioEngine::getSpectrum(int globalCh) const {
int n = analyzer_.numSpectra();
if (globalCh < n) return analyzer_.channelSpectrum(globalCh);
globalCh -= n;
for (auto& ed : extraDevices_) {
int en = ed->analyzer.numSpectra();
if (globalCh < en) return ed->analyzer.channelSpectrum(globalCh);
globalCh -= en;
}
return analyzer_.channelSpectrum(0);
}
const std::vector<std::complex<float>>& AudioEngine::getComplex(int globalCh) const {
int n = analyzer_.numSpectra();
if (globalCh < n) return analyzer_.channelComplex(globalCh);
globalCh -= n;
for (auto& ed : extraDevices_) {
int en = ed->analyzer.numSpectra();
if (globalCh < en) return ed->analyzer.channelComplex(globalCh);
globalCh -= en;
}
return analyzer_.channelComplex(0);
}
const char* AudioEngine::getDeviceName(int globalCh) const {
int n = analyzer_.numSpectra();
if (globalCh < n) {
if (deviceIdx_ >= 0 && deviceIdx_ < static_cast<int>(devices_.size()))
return devices_[deviceIdx_].name.c_str();
for (int i = 0; i < static_cast<int>(devices_.size()); ++i)
if (deviceSelected_[i]) return devices_[i].name.c_str();
return "Audio Device";
}
globalCh -= n;
int devSel = 0;
for (int i = 0; i < static_cast<int>(devices_.size()) && i < kMaxChannels; ++i) {
if (!deviceSelected_[i]) continue;
++devSel;
if (devSel <= 1) continue;
int edIdx = devSel - 2;
if (edIdx < static_cast<int>(extraDevices_.size())) {
int en = extraDevices_[edIdx]->analyzer.numSpectra();
if (globalCh < en) return devices_[i].name.c_str();
globalCh -= en;
}
}
return "Audio Device";
}
// ── Math channels ────────────────────────────────────────────────────────────
void AudioEngine::computeMathChannels() {
int nPhys = totalNumSpectra();
int specSz = analyzer_.spectrumSize();
mathSpectra_.resize(mathChannels_.size());
for (size_t mi = 0; mi < mathChannels_.size(); ++mi) {
const auto& mc = mathChannels_[mi];
auto& out = mathSpectra_[mi];
out.resize(specSz);
if (!mc.enabled) {
std::fill(out.begin(), out.end(), kNoSignalDB);
continue;
}
int sx = std::clamp(mc.sourceX, 0, nPhys - 1);
int sy = std::clamp(mc.sourceY, 0, nPhys - 1);
const auto& xDB = getSpectrum(sx);
const auto& yDB = getSpectrum(sy);
const auto& xC = getComplex(sx);
const auto& yC = getComplex(sy);
for (int i = 0; i < specSz; ++i) {
float val = kNoSignalDB;
switch (mc.op) {
case MathOp::Negate: val = -xDB[i]; break;
case MathOp::Absolute: val = std::abs(xDB[i]); break;
case MathOp::Square: val = 2.0f * xDB[i]; break;
case MathOp::Cube: val = 3.0f * xDB[i]; break;
case MathOp::Sqrt: val = 0.5f * xDB[i]; break;
case MathOp::Log: {
float lin = std::pow(10.0f, xDB[i] / 10.0f);
val = 10.0f * std::log10(lin + kLogGuard);
break;
}
case MathOp::Add: {
float lx = std::pow(10.0f, xDB[i] / 10.0f);
float ly = std::pow(10.0f, yDB[i] / 10.0f);
float s = lx + ly;
val = (s > kLinearEpsilon) ? 10.0f * std::log10(s) : kNoSignalDB;
break;
}
case MathOp::Subtract: {
float lx = std::pow(10.0f, xDB[i] / 10.0f);
float ly = std::pow(10.0f, yDB[i] / 10.0f);
float d = std::abs(lx - ly);
val = (d > kLinearEpsilon) ? 10.0f * std::log10(d) : kNoSignalDB;
break;
}
case MathOp::Multiply:
val = xDB[i] + yDB[i];
break;
case MathOp::Phase: {
if (i < static_cast<int>(xC.size()) &&
i < static_cast<int>(yC.size())) {
auto cross = xC[i] * std::conj(yC[i]);
val = std::atan2(cross.imag(), cross.real())
* (180.0f / kPi);
}
break;
}
case MathOp::CrossCorr: {
if (i < static_cast<int>(xC.size()) &&
i < static_cast<int>(yC.size())) {
auto cross = xC[i] * std::conj(yC[i]);
float mag2 = std::norm(cross);
val = (mag2 > kLinearEpsilon) ? 10.0f * std::log10(mag2) : kNoSignalDB;
}
break;
}
default: break;
}
out[i] = val;
}
}
}
} // namespace baudmine

92
src/audio/AudioEngine.h Normal file
View File

@@ -0,0 +1,92 @@
#pragma once
#include "core/Types.h"
#include "audio/AudioSource.h"
#include "audio/MiniAudioSource.h"
#include "dsp/SpectrumAnalyzer.h"
#include <complex>
#include <memory>
#include <string>
#include <vector>
namespace baudmine {
class AudioEngine {
public:
AudioEngine();
// ── Device enumeration ──
void enumerateDevices();
const std::vector<MiniAudioSource::DeviceInfo>& devices() const { return devices_; }
// ── Source management ──
void openDevice(int deviceListIdx);
void openMultiDevice(const bool selected[], int maxDevs);
void openFile(const std::string& path, InputFormat format, double sampleRate, bool loop);
void closeAll();
bool hasSource() const { return audioSource_ != nullptr; }
AudioSource* source() { return audioSource_.get(); }
// ── Analyzer ──
AnalyzerSettings& settings() { return settings_; }
const AnalyzerSettings& settings() const { return settings_; }
const SpectrumAnalyzer& primaryAnalyzer() const { return analyzer_; }
void configure(const AnalyzerSettings& s);
int processAudio(); // returns number of new spectra from primary analyzer
void clearHistory();
void drainSources(); // flush stale audio from all real-time sources
// ── Unified channel view across all analyzers ──
int totalNumSpectra() const;
const std::vector<float>& getSpectrum(int globalCh) const;
const std::vector<std::complex<float>>& getComplex(int globalCh) const;
const char* getDeviceName(int globalCh) const;
int spectrumSize() const { return analyzer_.spectrumSize(); }
double binToFreq(int bin) const { return analyzer_.binToFreq(bin); }
// ── Math channels ──
std::vector<MathChannel>& mathChannels() { return mathChannels_; }
const std::vector<MathChannel>& mathChannels() const { return mathChannels_; }
const std::vector<std::vector<float>>& mathSpectra() const { return mathSpectra_; }
void computeMathChannels();
// ── Device selection state (for config persistence) ──
int deviceIdx() const { return deviceIdx_; }
void setDeviceIdx(int i) { deviceIdx_ = i; }
bool multiDeviceMode() const { return multiDeviceMode_; }
void setMultiDeviceMode(bool m) { multiDeviceMode_ = m; }
bool deviceSelected(int i) const { return (i >= 0 && i < kMaxChannels) ? deviceSelected_[i] : false; }
void setDeviceSelected(int i, bool v) { if (i >= 0 && i < kMaxChannels) deviceSelected_[i] = v; }
void clearDeviceSelections();
private:
struct ExtraDevice {
std::unique_ptr<AudioSource> source;
SpectrumAnalyzer analyzer;
std::vector<float> audioBuf;
};
// Sources
std::unique_ptr<AudioSource> audioSource_;
std::vector<float> audioBuf_;
std::vector<std::unique_ptr<ExtraDevice>> extraDevices_;
// DSP
SpectrumAnalyzer analyzer_;
AnalyzerSettings settings_;
// Math
std::vector<MathChannel> mathChannels_;
std::vector<std::vector<float>> mathSpectra_;
// Device state
std::vector<MiniAudioSource::DeviceInfo> devices_;
int deviceIdx_ = 0;
bool multiDeviceMode_ = false;
bool deviceSelected_[kMaxChannels] = {};
};
} // namespace baudmine

View File

@@ -16,6 +16,15 @@ constexpr int kMinFFTSize = 256;
constexpr int kMaxFFTSize = 65536; constexpr int kMaxFFTSize = 65536;
constexpr int kDefaultFFTSize = 4096; constexpr int kDefaultFFTSize = 4096;
constexpr int kWaterfallHistory = 2048; constexpr int kWaterfallHistory = 2048;
constexpr int kDefaultWindowWidth = 1400;
constexpr int kDefaultWindowHeight = 900;
// ── Shared display constants ────────────────────────────────────────────────
constexpr float kNoSignalDB = -200.0f; // sentinel for "no signal" in dB
constexpr float kZoomFactor = 0.85f; // scroll-wheel zoom step
constexpr float kMinLogBinFrac = 0.001f; // minimum bin fraction for log scale
constexpr double kPi = 3.14159265358979323846;
// ── Enumerations ───────────────────────────────────────────────────────────── // ── Enumerations ─────────────────────────────────────────────────────────────
@@ -87,6 +96,16 @@ inline const char* inputFormatName(InputFormat f) {
} }
} }
// ── Frequency range helpers ──────────────────────────────────────────────────
inline double freqMin(double sampleRate, bool isIQ) {
return isIQ ? -sampleRate / 2.0 : 0.0;
}
inline double freqMax(double sampleRate, bool isIQ) {
return sampleRate / 2.0;
}
// ── Formatting helpers ─────────────────────────────────────────────────────── // ── Formatting helpers ───────────────────────────────────────────────────────
// Format frequency into buf with fixed-width numeric field per unit range. // Format frequency into buf with fixed-width numeric field per unit range.
@@ -157,4 +176,53 @@ struct Color3 {
uint8_t r, g, b; uint8_t r, g, b;
}; };
// ── Channel math operations ──────────────────────────────────────────────────
enum class MathOp {
// Unary (on channel X)
Negate, // -x (negate dB)
Absolute, // |x| (absolute value of dB)
Square, // x^2 in linear → 2*x_dB
Cube, // x^3 in linear → 3*x_dB
Sqrt, // sqrt in linear → 0.5*x_dB
Log, // 10*log10(10^(x_dB/10) + 1) — compressed scale
// Binary (on channels X and Y)
Add, // linear(x) + linear(y) → dB
Subtract, // |linear(x) - linear(y)| → dB
Multiply, // x_dB + y_dB (multiply in linear = add in dB)
Phase, // angle(X_cplx * conj(Y_cplx)) in degrees
CrossCorr, // |X_cplx * conj(Y_cplx)| → dB
Count
};
inline bool mathOpIsBinary(MathOp op) {
return op >= MathOp::Add;
}
inline const char* mathOpName(MathOp op) {
switch (op) {
case MathOp::Negate: return "-x";
case MathOp::Absolute: return "|x|";
case MathOp::Square: return "x^2";
case MathOp::Cube: return "x^3";
case MathOp::Sqrt: return "sqrt(x)";
case MathOp::Log: return "log(x)";
case MathOp::Add: return "x + y";
case MathOp::Subtract: return "x - y";
case MathOp::Multiply: return "x * y";
case MathOp::Phase: return "phase(x,y)";
case MathOp::CrossCorr: return "xcorr(x,y)";
default: return "?";
}
}
struct MathChannel {
MathOp op = MathOp::Subtract;
int sourceX = 0;
int sourceY = 1;
float color[4] = {1.0f, 1.0f, 1.0f, 1.0f};
bool enabled = true;
bool waterfall = false;
};
} // namespace baudmine } // namespace baudmine

View File

@@ -4,8 +4,6 @@
namespace baudmine { namespace baudmine {
static constexpr double kPi = 3.14159265358979323846;
void WindowFunctions::generate(WindowType type, int size, std::vector<float>& out, void WindowFunctions::generate(WindowType type, int size, std::vector<float>& out,
float kaiserBeta) { float kaiserBeta) {
out.resize(size); out.resize(size);

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,10 @@
#include "core/Types.h" #include "core/Types.h"
#include "core/Config.h" #include "core/Config.h"
#include "dsp/SpectrumAnalyzer.h" #include "audio/AudioEngine.h"
#include "audio/AudioSource.h" #include "ui/UIState.h"
#include "audio/MiniAudioSource.h" #include "ui/ControlPanel.h"
#include "ui/DisplayPanel.h"
#include "ui/ColorMap.h" #include "ui/ColorMap.h"
#include "ui/WaterfallDisplay.h" #include "ui/WaterfallDisplay.h"
#include "ui/SpectrumDisplay.h" #include "ui/SpectrumDisplay.h"
@@ -12,62 +13,10 @@
#include "ui/Measurements.h" #include "ui/Measurements.h"
#include <SDL.h> #include <SDL.h>
#include <complex>
#include <memory>
#include <string> #include <string>
#include <vector>
namespace baudmine { namespace baudmine {
// ── Channel math operations ──────────────────────────────────────────────────
enum class MathOp {
// Unary (on channel X)
Negate, // -x (negate dB)
Absolute, // |x| (absolute value of dB)
Square, // x^2 in linear → 2*x_dB
Cube, // x^3 in linear → 3*x_dB
Sqrt, // sqrt in linear → 0.5*x_dB
Log, // 10*log10(10^(x_dB/10) + 1) — compressed scale
// Binary (on channels X and Y)
Add, // linear(x) + linear(y) → dB
Subtract, // |linear(x) - linear(y)| → dB
Multiply, // x_dB + y_dB (multiply in linear = add in dB)
Phase, // angle(X_cplx * conj(Y_cplx)) in degrees
CrossCorr, // |X_cplx * conj(Y_cplx)| → dB
Count
};
inline bool mathOpIsBinary(MathOp op) {
return op >= MathOp::Add;
}
inline const char* mathOpName(MathOp op) {
switch (op) {
case MathOp::Negate: return "-x";
case MathOp::Absolute: return "|x|";
case MathOp::Square: return "x^2";
case MathOp::Cube: return "x^3";
case MathOp::Sqrt: return "sqrt(x)";
case MathOp::Log: return "log(x)";
case MathOp::Add: return "x + y";
case MathOp::Subtract: return "x - y";
case MathOp::Multiply: return "x * y";
case MathOp::Phase: return "phase(x,y)";
case MathOp::CrossCorr: return "xcorr(x,y)";
default: return "?";
}
}
struct MathChannel {
MathOp op = MathOp::Subtract;
int sourceX = 0;
int sourceY = 1;
ImVec4 color = {1.0f, 1.0f, 1.0f, 1.0f};
bool enabled = true;
bool waterfall = false; // include on waterfall overlay
};
class Application { class Application {
public: public:
Application(); Application();
@@ -81,17 +30,11 @@ public:
private: private:
void processAudio(); void processAudio();
void render(); void render();
void renderControlPanel();
void renderSpectrumPanel();
void renderWaterfallPanel();
void handleSpectrumInput(float posX, float posY, float sizeX, float sizeY);
void openPortAudio(); void openDevice();
void openMultiDevice(); void openMultiDevice();
void openFile(const std::string& path, InputFormat format, double sampleRate); void openFile(const std::string& path, InputFormat format, double sampleRate);
void updateAnalyzerSettings(); void updateAnalyzerSettings();
void computeMathChannels();
void renderMathPanel();
void loadConfig(); void loadConfig();
void saveConfig() const; void saveConfig() const;
@@ -101,146 +44,43 @@ private:
SDL_GLContext glContext_ = nullptr; SDL_GLContext glContext_ = nullptr;
bool running_ = false; bool running_ = false;
// Audio // Core subsystems
std::unique_ptr<AudioSource> audioSource_; AudioEngine audio_;
std::vector<float> audioBuf_; // temp read buffer UIState ui_;
ControlPanel controlPanel_;
DisplayPanel displayPanel_;
// Extra devices (multi-device mode): each gets its own source + analyzer. // Shared UI components
struct ExtraDevice {
std::unique_ptr<AudioSource> source;
SpectrumAnalyzer analyzer;
std::vector<float> audioBuf;
};
std::vector<std::unique_ptr<ExtraDevice>> extraDevices_;
// Helpers to present a unified channel view across all analyzers.
int totalNumSpectra() const;
const std::vector<float>& getSpectrum(int globalCh) const;
const std::vector<std::complex<float>>& getComplex(int globalCh) const;
// Returns the device name that owns a given global channel index.
const char* getDeviceName(int globalCh) const;
// DSP
SpectrumAnalyzer analyzer_;
AnalyzerSettings settings_;
// UI state
ColorMap colorMap_; ColorMap colorMap_;
WaterfallDisplay waterfall_; WaterfallDisplay waterfall_;
SpectrumDisplay specDisplay_; SpectrumDisplay specDisplay_;
Cursors cursors_; Cursors cursors_;
Measurements measurements_; Measurements measurements_;
// Display settings // UI scaling
float minDB_ = -120.0f; bool vsync_ = true;
float maxDB_ = 0.0f; float uiScale_ = 0.0f;
FreqScale freqScale_ = FreqScale::Linear; float appliedScale_ = 0.0f;
bool paused_ = false; float pendingScale_ = 0.0f;
bool vsync_ = true; float logicalScale_ = 1.0f;
float uiScale_ = 0.0f; // 0 = auto (use DPI), >0 = manual override float lastDpr_ = 0.0f;
float appliedScale_ = 0.0f; // currently applied user-facing scale
float pendingScale_ = 0.0f; // deferred scale (applied before next frame)
float logicalScale_ = 1.0f; // scale after compensating for framebuffer DPI
float lastDpr_ = 0.0f; // last devicePixelRatio (to detect changes)
void applyUIScale(float scale); void applyUIScale(float scale);
void requestUIScale(float scale); // safe to call mid-frame void requestUIScale(float scale);
float systemDpiScale() const;
void syncCanvasSize(); void syncCanvasSize();
// (waterfallW_ removed — texture width tracks bin count automatically)
// (waterfallH_ removed — fixed history depth of 1024 rows)
// 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 095%)
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<MiniAudioSource::DeviceInfo> paDevices_;
int paDeviceIdx_ = 0;
bool paDeviceSelected_[kMaxChannels] = {}; // multi-device checkboxes
bool multiDeviceMode_ = false; // true = use multiple devices as channels
// Channel colors (up to kMaxChannels). Defaults: L=purple, R=green.
ImVec4 channelColors_[kMaxChannels] = {
{0.20f, 0.90f, 0.30f, 1.0f}, // green
{0.70f, 0.30f, 1.00f, 1.0f}, // purple
{1.00f, 0.55f, 0.00f, 1.0f}, // orange
{0.00f, 0.75f, 1.00f, 1.0f}, // cyan
{1.00f, 0.25f, 0.25f, 1.0f}, // red
{1.00f, 1.00f, 0.30f, 1.0f}, // yellow
{0.50f, 0.80f, 0.50f, 1.0f}, // light green
{0.80f, 0.50f, 0.80f, 1.0f}, // pink
};
int waterfallChannel_ = 0; // which channel drives the waterfall (single mode)
bool waterfallMultiCh_ = true; // true = multi-channel overlay mode
bool channelEnabled_[kMaxChannels] = {true,true,true,true,true,true,true,true};
// Math channels
std::vector<MathChannel> mathChannels_;
std::vector<std::vector<float>> mathSpectra_; // computed each frame
// Frequency zoom/pan (normalized 01 over full bandwidth)
float viewLo_ = 0.0f; // left edge
float viewHi_ = 0.5f; // right edge (default 2x zoom from left)
// Spectrum/waterfall split ratio (fraction of content height for spectrum)
float spectrumFrac_ = 0.35f;
bool draggingSplit_ = false;
// Panel geometry (stored for cursor interaction)
float specPosX_ = 0, specPosY_ = 0, specSizeX_ = 0, specSizeY_ = 0;
float wfPosX_ = 0, wfPosY_ = 0, wfSizeX_ = 0, wfSizeY_ = 0;
// Hover state: which panel is being hovered
enum class HoverPanel { None, Spectrum, Waterfall };
HoverPanel hoverPanel_ = HoverPanel::None;
float hoverWfTimeOffset_ = 0.0f; // seconds from newest line
// Touch gesture state (pinch-zoom / two-finger pan)
struct TouchState {
int count = 0; // active finger count
float startDist = 0.0f; // initial distance between two fingers
float startLo = 0.0f; // viewLo_ at gesture start
float startHi = 0.0f; // viewHi_ at gesture start
float startCenterX = 0.0f; // midpoint screen-X at gesture start
float lastCenterX = 0.0f; // last midpoint screen-X
float lastDist = 0.0f; // last distance between fingers
} touch_;
void handleTouchEvent(const SDL_Event& event);
// Config persistence
Config config_;
// UI visibility // UI visibility
bool showSidebar_ = true; bool showSidebar_ = true;
#ifndef IMGUI_DISABLE_DEBUG_TOOLS #ifndef IMGUI_DISABLE_DEBUG_TOOLS
// ImGui debug windows
bool showDemoWindow_ = false; bool showDemoWindow_ = false;
bool showMetricsWindow_ = false; bool showMetricsWindow_ = false;
bool showDebugLog_ = false; bool showDebugLog_ = false;
bool showStackTool_ = false; bool showStackTool_ = false;
#endif #endif
// Pre-allocated scratch buffers (avoid per-frame heap allocations) // Config persistence
std::vector<std::vector<float>> wfSpectraScratch_; Config config_;
std::vector<WaterfallChannelInfo> wfChInfoScratch_;
std::vector<std::vector<float>> allSpectraScratch_;
std::vector<ChannelStyle> stylesScratch_;
}; };
} // namespace baudmine } // namespace baudmine

396
src/ui/ControlPanel.cpp Normal file
View File

@@ -0,0 +1,396 @@
#include "ui/ControlPanel.h"
#include "audio/AudioEngine.h"
#include "ui/SpectrumDisplay.h"
#include "ui/Cursors.h"
#include "ui/Measurements.h"
#include "ui/ColorMap.h"
#include "ui/WaterfallDisplay.h"
#include <imgui.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace baudmine {
void ControlPanel::render(AudioEngine& audio, UIState& ui,
SpectrumDisplay& specDisplay, Cursors& cursors,
Measurements& measurements, ColorMap& colorMap,
WaterfallDisplay& waterfall) {
const auto& settings = audio.settings();
// ── Playback ──
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3.0f;
if (ImGui::Button(ui.paused ? "Resume" : "Pause", {btnW, 0}))
ui.paused = !ui.paused;
ImGui::SameLine();
if (ImGui::Button("Clear", {btnW, 0})) {
audio.clearHistory();
}
ImGui::SameLine();
if (ImGui::Button("Peak", {btnW, 0})) {
int pkCh = std::clamp(ui.waterfallChannel, 0, audio.totalNumSpectra() - 1);
cursors.snapToPeak(audio.getSpectrum(pkCh),
settings.sampleRate, settings.isIQ,
settings.fftSize);
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Snap cursor A to peak");
// ── FFT ──
ImGui::Spacing();
if (ImGui::CollapsingHeader("FFT", ImGuiTreeNodeFlags_DefaultOpen)) {
const char* sizeNames[] = {"256", "512", "1024", "2048", "4096",
"8192", "16384", "32768", "65536"};
float availSpace = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x);
ImGui::SetNextItemWidth(availSpace * 0.35f);
if (ImGui::Combo("##fftsize", &fftSizeIdx, sizeNames, kNumFFTSizes)) {
audio.settings().fftSize = kFFTSizes[fftSizeIdx];
flagUpdate();
flagSave();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("FFT Size");
ImGui::SameLine();
const char* winNames[] = {"Rectangular", "Hann", "Hamming", "Blackman",
"Blackman-Harris", "Kaiser", "Flat Top"};
ImGui::SetNextItemWidth(availSpace * 0.65f);
if (ImGui::Combo("##window", &windowIdx, winNames,
static_cast<int>(WindowType::Count))) {
audio.settings().window = static_cast<WindowType>(windowIdx);
flagUpdate();
flagSave();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Window Function");
if (settings.window == WindowType::Kaiser) {
ImGui::SetNextItemWidth(-1);
if (ImGui::SliderFloat("##kaiser", &audio.settings().kaiserBeta, 0.0f, 20.0f, "Kaiser: %.1f"))
flagUpdate();
}
// Overlap
{
int hopSamples = static_cast<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);
audio.settings().overlap = overlapPct / 100.0f;
flagUpdate();
flagSave();
}
char overlayText[64];
std::snprintf(overlayText, sizeof(overlayText), "%.1f%% (%d samples)", overlapPct, overlapSamples);
ImVec2 textSize = ImGui::CalcTextSize(overlayText);
ImVec2 rMin = ImGui::GetItemRectMin();
ImVec2 rMax = ImGui::GetItemRectMax();
float tx = rMin.x + ((rMax.x - rMin.x) - textSize.x) * 0.5f;
float ty = rMin.y + ((rMax.y - rMin.y) - textSize.y) * 0.5f;
ImGui::GetWindowDrawList()->AddText({tx, ty}, IM_COL32(255, 255, 255, 220), overlayText);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Overlap");
}
}
// ── Display ──
ImGui::Spacing();
if (ImGui::CollapsingHeader("Display", ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::SetNextItemWidth(-1);
ImGui::DragFloatRange2("##dbrange", &ui.minDB, &ui.maxDB, 1.0f, -200.0f, 20.0f,
"Min: %.0f dB", "Max: %.0f dB");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("dB Range (min / max)");
ImGui::Checkbox("Peak Hold", &specDisplay.peakHoldEnable);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Draws a \"maximum\" line in the spectrogram");
if (specDisplay.peakHoldEnable) {
ImGui::SameLine();
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x
- ImGui::CalcTextSize("Clear").x
- ImGui::GetStyle().ItemSpacing.x
- ImGui::GetStyle().FramePadding.x * 2);
ImGui::SliderFloat("##decay", &specDisplay.peakHoldDecay, 0.0f, 120.0f, "%.0f dB/s");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Decay rate");
ImGui::SameLine();
if (ImGui::SmallButton("Clear##peakhold"))
specDisplay.clearPeakHold();
}
{
bool isLog = (ui.freqScale == FreqScale::Logarithmic);
bool canLog = !settings.isIQ;
ImGui::AlignTextToFramePadding();
ImGui::Text("Freq. scale:");
ImGui::SameLine();
if (ImGui::Button(isLog ? "Logarithmic" : "Linear", {ImGui::GetContentRegionAvail().x, 0})) {
if (canLog) {
constexpr float kMinBF = kMinLogBinFrac;
float logMin = std::log10(kMinBF);
auto screenToBin = [&](float sf) -> float {
if (isLog) return std::pow(10.0f, logMin + sf * (0.0f - logMin));
return sf;
};
auto binToScreen = [&](float bf, bool toLog) -> float {
if (toLog) {
if (bf < kMinBF) bf = kMinBF;
return (std::log10(bf) - logMin) / (0.0f - logMin);
}
return bf;
};
float bfLo = screenToBin(ui.viewLo);
float bfHi = screenToBin(ui.viewHi);
bool newLog = !isLog;
ui.freqScale = newLog ? FreqScale::Logarithmic : FreqScale::Linear;
ui.viewLo = std::clamp(binToScreen(bfLo, newLog), 0.0f, 1.0f);
ui.viewHi = std::clamp(binToScreen(bfHi, newLog), 0.0f, 1.0f);
if (ui.viewHi <= ui.viewLo) { ui.viewLo = 0.0f; ui.viewHi = 1.0f; }
flagSave();
}
}
if (!canLog && ImGui::IsItemHovered())
ImGui::SetTooltip("Log scale not available in I/Q mode");
}
{
float span = ui.viewHi - ui.viewLo;
float zoomX = 1.0f / span;
float resetBtnW = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2;
float zoomLabelW = ImGui::CalcTextSize("Zoom:").x + ImGui::GetStyle().ItemSpacing.x;
float sliderW = ImGui::GetContentRegionAvail().x - zoomLabelW - resetBtnW - ImGui::GetStyle().ItemSpacing.x;
ImGui::AlignTextToFramePadding();
ImGui::Text("Zoom:");
ImGui::SameLine();
ImGui::SetNextItemWidth(sliderW);
if (ImGui::SliderFloat("##zoom", &zoomX, 1.0f, 200.0f, "%.1fx", ImGuiSliderFlags_Logarithmic)) {
zoomX = std::clamp(zoomX, 1.0f, 1000.0f);
float newSpan = 1.0f / zoomX;
ui.viewLo = 0.0f;
ui.viewHi = std::clamp(newSpan, 0.0f, 1.0f);
}
ImGui::SameLine();
if (ImGui::SmallButton("Reset##zoom")) {
ui.viewLo = 0.0f;
ui.viewHi = 0.5f;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Reset to 2x zoom");
}
}
// ── Channels ──
ImGui::Spacing();
{
int nCh = audio.totalNumSpectra();
bool isMulti = ui.waterfallMultiCh && nCh > 1;
float widgetW = (nCh > 1) ? ImGui::CalcTextSize(" Multi ").x + ImGui::GetStyle().FramePadding.x * 2 : 0.0f;
float gap = ImGui::GetStyle().ItemSpacing.x * 0.25f;
ImVec2 hdrMin = ImGui::GetCursorScreenPos();
float winLeft = ImGui::GetWindowPos().x;
float hdrRight = hdrMin.x + ImGui::GetContentRegionAvail().x;
ImGui::PushClipRect({winLeft, hdrMin.y}, {hdrRight - widgetW - gap, hdrMin.y + 200}, true);
bool headerOpen = ImGui::CollapsingHeader("##channels_hdr",
ImGuiTreeNodeFlags_DefaultOpen |
ImGuiTreeNodeFlags_AllowOverlap);
ImGui::PopClipRect();
ImGui::SameLine();
ImGui::Text("Channels");
if (nCh > 1) {
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - widgetW + ImGui::GetStyle().FramePadding.x);
if (ImGui::Button(isMulti ? " Multi " : "Single ", {widgetW, 0})) {
ui.waterfallMultiCh = !ui.waterfallMultiCh;
}
}
if (headerOpen) {
if (isMulti) {
static const char* defaultNames[] = {
"Left", "Right", "Ch 3", "Ch 4", "Ch 5", "Ch 6", "Ch 7", "Ch 8"
};
for (int ch = 0; ch < nCh && ch < kMaxChannels; ++ch) {
ImGui::PushID(ch);
ImGui::Checkbox("##en", &ui.channelEnabled[ch]);
ImGui::SameLine();
ImGui::ColorEdit3(defaultNames[ch], &ui.channelColors[ch].x,
ImGuiColorEditFlags_NoInputs);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", audio.getDeviceName(ch));
ImGui::PopID();
}
} else {
const char* cmNames[] = {"Magma", "Viridis", "Inferno", "Plasma", "Grayscale"};
ImGui::SetNextItemWidth(-1);
if (ImGui::Combo("##colormap", &colorMapIdx, cmNames,
static_cast<int>(ColorMapType::Count))) {
colorMap.setType(static_cast<ColorMapType>(colorMapIdx));
waterfall.setColorMap(colorMap);
flagSave();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Color Map");
if (nCh > 1) {
ImGui::SetNextItemWidth(-1);
if (ImGui::SliderInt("##wfch", &ui.waterfallChannel, 0, nCh - 1))
ui.waterfallChannel = std::clamp(ui.waterfallChannel, 0, nCh - 1);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Waterfall Channel");
}
}
}
}
// ── Math ──
ImGui::Spacing();
{
float btnW2 = ImGui::GetFrameHeight();
float gap = ImGui::GetStyle().ItemSpacing.x * 0.25f;
ImVec2 hdrMin = ImGui::GetCursorScreenPos();
float winLeft = ImGui::GetWindowPos().x;
float hdrRight = hdrMin.x + ImGui::GetContentRegionAvail().x;
ImGui::PushClipRect({winLeft, hdrMin.y}, {hdrRight - btnW2 - gap, hdrMin.y + 200}, true);
bool mathOpen = ImGui::CollapsingHeader("##math_hdr",
ImGuiTreeNodeFlags_DefaultOpen |
ImGuiTreeNodeFlags_AllowOverlap);
ImGui::PopClipRect();
ImGui::SameLine();
ImGui::Text("Math");
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - btnW2 + ImGui::GetStyle().FramePadding.x);
if (ImGui::Button("+##addmath", {btnW2, 0})) {
int nPhys = audio.totalNumSpectra();
MathChannel mc;
mc.op = MathOp::Subtract;
mc.sourceX = 0;
mc.sourceY = std::min(1, nPhys - 1);
mc.color[0] = 1.0f; mc.color[1] = 1.0f; mc.color[2] = 0.5f; mc.color[3] = 1.0f;
audio.mathChannels().push_back(mc);
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Add math channel");
if (mathOpen) {
renderMathPanel(audio);
}
}
// ── Cursors ──
ImGui::Spacing();
{
float btnW2 = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2;
float gap = ImGui::GetStyle().ItemSpacing.x * 0.25f;
ImVec2 hdrMin = ImGui::GetCursorScreenPos();
float winLeft = ImGui::GetWindowPos().x;
float hdrRight = hdrMin.x + ImGui::GetContentRegionAvail().x;
ImGui::PushClipRect({winLeft, hdrMin.y}, {hdrRight - btnW2 - gap, hdrMin.y + 200}, true);
bool cursorsOpen = ImGui::CollapsingHeader("##cursors_hdr",
ImGuiTreeNodeFlags_DefaultOpen |
ImGuiTreeNodeFlags_AllowOverlap);
ImGui::PopClipRect();
ImGui::SameLine();
ImGui::Text("Cursors");
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - btnW2 + ImGui::GetStyle().FramePadding.x);
if (ImGui::SmallButton("Reset##cursors")) {
cursors.cursorA.active = false;
cursors.cursorB.active = false;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Clear cursors A and B");
if (cursorsOpen) {
bool prevSnap = cursors.snapToPeaks;
cursors.drawPanel();
if (cursors.snapToPeaks != prevSnap) flagSave();
}
}
// ── Measurements ──
ImGui::Spacing();
{
float cbW = ImGui::GetFrameHeight();
float gap = ImGui::GetStyle().ItemSpacing.x * 0.25f;
ImVec2 hdrMin = ImGui::GetCursorScreenPos();
float winLeft = ImGui::GetWindowPos().x;
float hdrRight = hdrMin.x + ImGui::GetContentRegionAvail().x;
ImGui::PushClipRect({winLeft, hdrMin.y}, {hdrRight - cbW - gap, hdrMin.y + 200}, true);
bool headerOpen = ImGui::CollapsingHeader("##meas_hdr",
ImGuiTreeNodeFlags_DefaultOpen |
ImGuiTreeNodeFlags_AllowOverlap);
ImGui::PopClipRect();
ImGui::SameLine();
ImGui::Text("Measurements");
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - cbW + ImGui::GetStyle().FramePadding.x);
ImGui::Checkbox("##meas_en", &measurements.enabled);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Enable measurements");
if (headerOpen) {
float prevMin = measurements.traceMinFreq;
float prevMax = measurements.traceMaxFreq;
measurements.drawPanel();
if (measurements.traceMinFreq != prevMin || measurements.traceMaxFreq != prevMax)
flagSave();
}
}
// ── Status (bottom) ──
ImGui::Separator();
ImGui::TextDisabled("Mode: %s", settings.isIQ ? "I/Q"
: (settings.numChannels > 1 ? "Multi-ch" : "Real"));
}
void ControlPanel::renderMathPanel(AudioEngine& audio) {
int nPhys = audio.totalNumSpectra();
auto& mathChannels = audio.mathChannels();
static const char* chNames[] = {
"Ch 0 (L)", "Ch 1 (R)", "Ch 2", "Ch 3", "Ch 4", "Ch 5", "Ch 6", "Ch 7"
};
int toRemove = -1;
for (int mi = 0; mi < static_cast<int>(mathChannels.size()); ++mi) {
auto& mc = mathChannels[mi];
ImGui::PushID(1000 + mi);
ImGui::Checkbox("##en", &mc.enabled);
ImGui::SameLine();
ImGui::ColorEdit3("##col", mc.color, ImGuiColorEditFlags_NoInputs);
ImGui::SameLine();
if (ImGui::BeginCombo("##op", mathOpName(mc.op), ImGuiComboFlags_NoPreview)) {
for (int o = 0; o < static_cast<int>(MathOp::Count); ++o) {
auto op = static_cast<MathOp>(o);
if (ImGui::Selectable(mathOpName(op), mc.op == op))
mc.op = op;
}
ImGui::EndCombo();
}
ImGui::SameLine();
ImGui::Text("%s", mathOpName(mc.op));
ImGui::SetNextItemWidth(80);
ImGui::Combo("X", &mc.sourceX, chNames, std::min(nPhys, kMaxChannels));
if (mathOpIsBinary(mc.op)) {
ImGui::SameLine();
ImGui::SetNextItemWidth(80);
ImGui::Combo("Y", &mc.sourceY, chNames, std::min(nPhys, kMaxChannels));
}
ImGui::SameLine();
ImGui::Checkbox("WF", &mc.waterfall);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Show on waterfall");
ImGui::SameLine();
if (ImGui::SmallButton("X##del"))
toRemove = mi;
ImGui::PopID();
}
if (toRemove >= 0)
mathChannels.erase(mathChannels.begin() + toRemove);
}
} // namespace baudmine

52
src/ui/ControlPanel.h Normal file
View File

@@ -0,0 +1,52 @@
#pragma once
#include "core/Types.h"
#include "ui/UIState.h"
#include <string>
namespace baudmine {
class AudioEngine;
class SpectrumDisplay;
class Cursors;
class Measurements;
class ColorMap;
class WaterfallDisplay;
class ControlPanel {
public:
void render(AudioEngine& audio, UIState& ui,
SpectrumDisplay& specDisplay, Cursors& cursors,
Measurements& measurements, ColorMap& colorMap,
WaterfallDisplay& waterfall);
// Consume action flags set during render(). Returns true once, then resets.
bool consumeSaveRequest() { bool v = needsSave_; needsSave_ = false; return v; }
bool consumeUpdateRequest() { bool v = needsUpdate_; needsUpdate_ = false; return v; }
// FFT / analysis controls
static constexpr int kFFTSizes[] = {256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536};
static constexpr int kNumFFTSizes = 9;
int fftSizeIdx = 4;
float overlapPct = 50.0f;
int windowIdx = static_cast<int>(WindowType::BlackmanHarris);
int colorMapIdx = static_cast<int>(ColorMapType::Magma);
// File playback
std::string filePath;
int fileFormatIdx = 0;
float fileSampleRate = 48000.0f;
bool fileLoop = true;
private:
void renderMathPanel(AudioEngine& audio);
bool needsSave_ = false;
bool needsUpdate_ = false;
void flagSave() { needsSave_ = true; }
void flagUpdate() { needsUpdate_ = true; }
};
} // namespace baudmine

459
src/ui/DisplayPanel.cpp Normal file
View File

@@ -0,0 +1,459 @@
#include "ui/DisplayPanel.h"
#include "audio/AudioEngine.h"
#include <imgui.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace baudmine {
namespace {
// Zoom the view range centered on a screen-fraction cursor position.
void zoomView(float& viewLo, float& viewHi, float cursorScreenFrac, float wheelDir) {
float viewFrac = viewLo + cursorScreenFrac * (viewHi - viewLo);
float factor = (wheelDir > 0) ? kZoomFactor : 1.0f / kZoomFactor;
float newSpan = std::clamp((viewHi - viewLo) * factor, 0.001f, 1.0f);
float newLo = viewFrac - cursorScreenFrac * newSpan;
float newHi = newLo + newSpan;
if (newLo < 0.0f) { newHi -= newLo; newLo = 0.0f; }
if (newHi > 1.0f) { newLo -= (newHi - 1.0f); newHi = 1.0f; }
viewLo = std::clamp(newLo, 0.0f, 1.0f);
viewHi = std::clamp(newHi, 0.0f, 1.0f);
}
// Pan the view range by a screen-pixel delta.
void panView(float& viewLo, float& viewHi, float dxPixels, float panelWidth) {
float panFrac = -dxPixels / panelWidth * (viewHi - viewLo);
float span = viewHi - viewLo;
float newLo = viewLo + panFrac;
float newHi = viewHi + panFrac;
if (newLo < 0.0f) { newLo = 0.0f; newHi = span; }
if (newHi > 1.0f) { newHi = 1.0f; newLo = 1.0f - span; }
viewLo = newLo;
viewHi = newHi;
}
} // anonymous namespace
void DisplayPanel::renderSpectrum(AudioEngine& audio, UIState& ui,
SpectrumDisplay& specDisplay, Cursors& cursors,
Measurements& measurements) {
const auto& settings = audio.settings();
float availW = ImGui::GetContentRegionAvail().x;
float specH = ImGui::GetContentRegionAvail().y;
ImVec2 pos = ImGui::GetCursorScreenPos();
specPosX = pos.x;
specPosY = pos.y;
specSizeX = availW;
specSizeY = specH;
int nPhys = audio.totalNumSpectra();
const auto& mathChannels = audio.mathChannels();
const auto& mathSpectra = audio.mathSpectra();
int nMath = static_cast<int>(mathSpectra.size());
allSpectraScratch_.clear();
stylesScratch_.clear();
for (int ch = 0; ch < nPhys; ++ch) {
if (!ui.channelEnabled[ch % kMaxChannels]) continue;
allSpectraScratch_.push_back(audio.getSpectrum(ch));
const auto& c = ui.channelColors[ch % kMaxChannels];
uint8_t r = static_cast<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);
stylesScratch_.push_back({IM_COL32(r, g, b, 220), IM_COL32(r, g, b, 35)});
}
for (int mi = 0; mi < nMath; ++mi) {
if (mi < static_cast<int>(mathChannels.size()) && mathChannels[mi].enabled) {
allSpectraScratch_.push_back(mathSpectra[mi]);
const auto& c = mathChannels[mi].color;
uint8_t r = static_cast<uint8_t>(c[0] * 255);
uint8_t g = static_cast<uint8_t>(c[1] * 255);
uint8_t b = static_cast<uint8_t>(c[2] * 255);
stylesScratch_.push_back({IM_COL32(r, g, b, 220), IM_COL32(r, g, b, 35)});
}
}
specDisplay.updatePeakHold(allSpectraScratch_);
specDisplay.draw(allSpectraScratch_, stylesScratch_, ui.minDB, ui.maxDB,
settings.sampleRate, settings.isIQ, ui.freqScale,
specPosX, specPosY, specSizeX, specSizeY,
ui.viewLo, ui.viewHi);
cursors.draw(specDisplay, specPosX, specPosY, specSizeX, specSizeY,
settings.sampleRate, settings.isIQ, ui.freqScale, ui.minDB, ui.maxDB,
ui.viewLo, ui.viewHi);
measurements.draw(specDisplay, specPosX, specPosY, specSizeX, specSizeY,
settings.sampleRate, settings.isIQ, ui.freqScale, ui.minDB, ui.maxDB,
ui.viewLo, ui.viewHi);
handleSpectrumInput(audio, ui, specDisplay, cursors,
specPosX, specPosY, specSizeX, specSizeY);
ImGui::Dummy({availW, specH});
}
void DisplayPanel::renderWaterfall(AudioEngine& audio, UIState& ui,
WaterfallDisplay& waterfall, SpectrumDisplay& specDisplay,
Cursors& cursors, Measurements& measurements,
ColorMap& colorMap) {
const auto& settings = audio.settings();
float availW = ImGui::GetContentRegionAvail().x;
constexpr float kSplitterH = 6.0f;
float parentH = ImGui::GetContentRegionAvail().y;
float availH = (parentH - kSplitterH) * (1.0f - spectrumFrac);
int neededH = std::max(1024, static_cast<int>(availH) + 1);
int binCount = std::max(1, audio.spectrumSize());
if (binCount != waterfall.width() || waterfall.height() < neededH) {
waterfall.resize(binCount, neededH);
waterfall.setColorMap(colorMap);
}
if (waterfall.textureID()) {
ImVec2 pos = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
auto texID = static_cast<ImTextureID>(waterfall.textureID());
int h = waterfall.height();
int screenRows = std::min(static_cast<int>(availH), h);
int newestRow = (waterfall.currentRow() + 1) % h;
float rowToV = 1.0f / h;
bool logMode = (ui.freqScale == FreqScale::Logarithmic && !settings.isIQ);
auto drawSpan = [&](int rowStart, int rowCount, float yStart, float spanH) {
float v0 = rowStart * rowToV;
float v1 = (rowStart + rowCount) * rowToV;
if (!logMode) {
dl->AddImage(texID,
{pos.x, yStart},
{pos.x + availW, yStart + spanH},
{ui.viewLo, v1}, {ui.viewHi, v0});
} else {
constexpr float kMinBinFrac = kMinLogBinFrac;
float logMin2 = std::log10(kMinBinFrac);
float logMax2 = 0.0f;
int numStrips = std::min(512, static_cast<int>(availW));
for (int s = 0; s < numStrips; ++s) {
float sL = static_cast<float>(s) / numStrips;
float sR = static_cast<float>(s + 1) / numStrips;
float vfL = ui.viewLo + sL * (ui.viewHi - ui.viewLo);
float vfR = ui.viewLo + sR * (ui.viewHi - ui.viewLo);
float uL = std::pow(10.0f, logMin2 + vfL * (logMax2 - logMin2));
float uR = std::pow(10.0f, logMin2 + vfR * (logMax2 - logMin2));
dl->AddImage(texID,
{pos.x + sL * availW, yStart},
{pos.x + sR * availW, yStart + spanH},
{uL, v1}, {uR, v0});
}
}
};
float pxPerRow = availH / static_cast<float>(screenRows);
if (newestRow + screenRows <= h) {
drawSpan(newestRow, screenRows, pos.y, availH);
} else {
int firstCount = h - newestRow;
int secondCount = screenRows - firstCount;
float secondH = secondCount * pxPerRow;
if (secondCount > 0)
drawSpan(0, secondCount, pos.y, secondH);
float firstH = availH - secondH;
drawSpan(newestRow, firstCount, pos.y + secondH, firstH);
}
// ── Frequency axis labels ──
ImU32 textCol = IM_COL32(180, 180, 200, 200);
double freqFullMin = freqMin(settings.sampleRate, settings.isIQ);
double freqFullMax = freqMax(settings.sampleRate, settings.isIQ);
auto viewFracToFreq = [&](float vf) -> double {
if (logMode) {
constexpr float kMinBinFrac = kMinLogBinFrac;
float logMin2 = std::log10(kMinBinFrac);
float logMax2 = 0.0f;
float binFrac = std::pow(10.0f, logMin2 + vf * (logMax2 - logMin2));
return freqFullMin + binFrac * (freqFullMax - freqFullMin);
}
return freqFullMin + vf * (freqFullMax - freqFullMin);
};
int numLabels = 8;
for (int i = 0; i <= numLabels; ++i) {
float frac = static_cast<float>(i) / numLabels;
float vf = ui.viewLo + frac * (ui.viewHi - ui.viewLo);
double freq = viewFracToFreq(vf);
float x = pos.x + frac * availW;
char label[32];
if (std::abs(freq) >= 1e6)
std::snprintf(label, sizeof(label), "%.2fM", freq / 1e6);
else if (std::abs(freq) >= 1e3)
std::snprintf(label, sizeof(label), "%.1fk", freq / 1e3);
else
std::snprintf(label, sizeof(label), "%.0f", freq);
dl->AddText({x + 2, pos.y + 2}, textCol, label);
}
wfPosX = pos.x; wfPosY = pos.y; wfSizeX = availW; wfSizeY = availH;
measurements.drawWaterfall(specDisplay, wfPosX, wfPosY, wfSizeX, wfSizeY,
settings.sampleRate, settings.isIQ, ui.freqScale,
ui.viewLo, ui.viewHi, screenRows, audio.spectrumSize());
// ── Mouse interaction: zoom, pan & hover on waterfall ──
ImGuiIO& io = ImGui::GetIO();
float mx = io.MousePos.x;
float my = io.MousePos.y;
bool inWaterfall = mx >= pos.x && mx <= pos.x + availW &&
my >= pos.y && my <= pos.y + availH;
if (inWaterfall) {
hoverPanel = HoverPanel::Waterfall;
double freq = specDisplay.screenXToFreq(mx, pos.x, availW,
settings.sampleRate,
settings.isIQ, ui.freqScale,
ui.viewLo, ui.viewHi);
int bins = audio.spectrumSize();
double fMin = freqMin(settings.sampleRate, settings.isIQ);
double fMax = freqMax(settings.sampleRate, settings.isIQ);
int bin = static_cast<int>((freq - fMin) / (fMax - fMin) * (bins - 1));
bin = std::clamp(bin, 0, bins - 1);
float yFrac = 1.0f - (my - pos.y) / availH;
int hopSamples = static_cast<int>(settings.fftSize * (1.0f - settings.overlap));
if (hopSamples < 1) hopSamples = 1;
double secondsPerLine = static_cast<double>(hopSamples) / settings.sampleRate;
hoverWfTimeOff = static_cast<float>(yFrac * screenRows * secondsPerLine);
int curCh = std::clamp(ui.waterfallChannel, 0, audio.totalNumSpectra() - 1);
const auto& spec = audio.getSpectrum(curCh);
if (!spec.empty()) {
cursors.hover = {true, freq, spec[bin], bin};
}
}
if (inWaterfall) {
if (io.MouseWheel != 0)
zoomView(ui.viewLo, ui.viewHi, (mx - pos.x) / availW, io.MouseWheel);
if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 1.0f))
panView(ui.viewLo, ui.viewHi, io.MouseDelta.x, availW);
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Middle)) {
ui.viewLo = 0.0f;
ui.viewHi = 1.0f;
}
}
}
ImGui::Dummy({availW, availH});
}
void DisplayPanel::renderHoverOverlay(const AudioEngine& audio, const UIState& ui,
const Cursors& cursors, const SpectrumDisplay& specDisplay) {
if (!cursors.hover.active || specSizeX <= 0 || wfSizeX <= 0)
return;
const auto& settings = audio.settings();
ImDrawList* dl = ImGui::GetWindowDrawList();
float hx = specDisplay.freqToScreenX(cursors.hover.freq,
specPosX, specSizeX, settings.sampleRate,
settings.isIQ, ui.freqScale, ui.viewLo, ui.viewHi);
ImU32 hoverCol = IM_COL32(200, 200, 200, 80);
dl->AddLine({hx, specPosY}, {hx, specPosY + specSizeY}, hoverCol, 1.0f);
char freqLabel[48];
fmtFreq(freqLabel, sizeof(freqLabel), cursors.hover.freq);
ImVec2 tSz = ImGui::CalcTextSize(freqLabel);
float lx = std::min(hx + 4, wfPosX + wfSizeX - tSz.x - 4);
float ly = wfPosY + 2;
dl->AddRectFilled({lx - 2, ly - 1}, {lx + tSz.x + 2, ly + tSz.y + 1},
IM_COL32(0, 0, 0, 180));
dl->AddText({lx, ly}, IM_COL32(220, 220, 240, 240), freqLabel);
// Hover info (right side)
{
int bins = audio.spectrumSize();
double fMin = freqMin(settings.sampleRate, settings.isIQ);
double fMax = freqMax(settings.sampleRate, settings.isIQ);
double binCenterFreq = fMin + (static_cast<double>(cursors.hover.bin) + 0.5)
/ bins * (fMax - fMin);
char hoverBuf[128];
if (hoverPanel == HoverPanel::Spectrum) {
fmtFreqDB(hoverBuf, sizeof(hoverBuf), "", binCenterFreq, cursors.hover.dB);
} else if (hoverPanel == HoverPanel::Waterfall) {
fmtFreqTime(hoverBuf, sizeof(hoverBuf), "", binCenterFreq, -hoverWfTimeOff);
} else {
fmtFreq(hoverBuf, sizeof(hoverBuf), binCenterFreq);
}
ImU32 hoverTextCol = IM_COL32(100, 230, 130, 240);
float rightEdge = specPosX + specSizeX - 8;
float hy2 = specPosY + 4;
ImVec2 hSz = ImGui::CalcTextSize(hoverBuf);
dl->AddText({rightEdge - hSz.x, hy2}, hoverTextCol, hoverBuf);
}
}
void DisplayPanel::handleSpectrumInput(AudioEngine& audio, UIState& ui,
SpectrumDisplay& specDisplay, Cursors& cursors,
float posX, float posY, float sizeX, float sizeY) {
const auto& settings = audio.settings();
ImGuiIO& io = ImGui::GetIO();
float mx = io.MousePos.x;
float my = io.MousePos.y;
bool inRegion = mx >= posX && mx <= posX + sizeX &&
my >= posY && my <= posY + sizeY;
if (inRegion) {
hoverPanel = HoverPanel::Spectrum;
double freq = specDisplay.screenXToFreq(mx, posX, sizeX,
settings.sampleRate,
settings.isIQ, ui.freqScale,
ui.viewLo, ui.viewHi);
float dB = specDisplay.screenYToDB(my, posY, sizeY, ui.minDB, ui.maxDB);
int bins = audio.spectrumSize();
double fMin = freqMin(settings.sampleRate, settings.isIQ);
double fMax = freqMax(settings.sampleRate, settings.isIQ);
int bin = static_cast<int>((freq - fMin) / (fMax - fMin) * (bins - 1));
bin = std::clamp(bin, 0, bins - 1);
int curCh = std::clamp(ui.waterfallChannel, 0, audio.totalNumSpectra() - 1);
const auto& spec = audio.getSpectrum(curCh);
if (!spec.empty()) {
dB = spec[bin];
cursors.hover = {true, freq, dB, bin};
}
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
int cBin = cursors.snapToPeaks ? cursors.findLocalPeak(spec, bin, 10) : bin;
double cFreq = audio.binToFreq(cBin);
cursors.setCursorA(cFreq, spec[cBin], cBin);
}
if (ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
int cBin = cursors.snapToPeaks ? cursors.findLocalPeak(spec, bin, 10) : bin;
double cFreq = audio.binToFreq(cBin);
cursors.setCursorB(cFreq, spec[cBin], cBin);
}
{
if (io.MouseWheel != 0 && (io.KeyCtrl || io.KeyShift)) {
float zoom = io.MouseWheel * 5.0f;
ui.minDB += zoom;
ui.maxDB -= zoom;
if (ui.maxDB - ui.minDB < 10.0f) {
float mid = (ui.minDB + ui.maxDB) / 2.0f;
ui.minDB = mid - 5.0f;
ui.maxDB = mid + 5.0f;
}
}
else if (io.MouseWheel != 0) {
zoomView(ui.viewLo, ui.viewHi, (mx - posX) / sizeX, io.MouseWheel);
}
if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 1.0f))
panView(ui.viewLo, ui.viewHi, io.MouseDelta.x, sizeX);
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Middle)) {
ui.viewLo = 0.0f;
ui.viewHi = 1.0f;
}
}
} else {
if (hoverPanel != HoverPanel::Waterfall) {
hoverPanel = HoverPanel::None;
cursors.hover.active = false;
}
}
}
void DisplayPanel::handleTouch(const SDL_Event& event, UIState& ui, SDL_Window* window) {
if (event.type == SDL_FINGERDOWN) {
++touch_.count;
} else if (event.type == SDL_FINGERUP) {
touch_.count = std::max(0, touch_.count - 1);
}
if (touch_.count == 2 && event.type == SDL_FINGERDOWN) {
int w, h;
SDL_GetWindowSize(window, &w, &h);
SDL_TouchID tid = event.tfinger.touchId;
int nf = SDL_GetNumTouchFingers(tid);
if (nf >= 2) {
SDL_Finger* f0 = SDL_GetTouchFinger(tid, 0);
SDL_Finger* f1 = SDL_GetTouchFinger(tid, 1);
if (!f0 || !f1) return;
float x0 = f0->x * w, x1 = f1->x * w;
float dx = x1 - x0, dy = (f1->y - f0->y) * h;
touch_.startDist = std::sqrt(dx * dx + dy * dy);
touch_.lastDist = touch_.startDist;
touch_.startCenterX = (x0 + x1) * 0.5f;
touch_.lastCenterX = touch_.startCenterX;
touch_.startLo = ui.viewLo;
touch_.startHi = ui.viewHi;
}
}
if (touch_.count == 2 && event.type == SDL_FINGERMOTION) {
int w, h;
SDL_GetWindowSize(window, &w, &h);
SDL_TouchID tid = event.tfinger.touchId;
int nf = SDL_GetNumTouchFingers(tid);
if (nf >= 2) {
SDL_Finger* f0 = SDL_GetTouchFinger(tid, 0);
SDL_Finger* f1 = SDL_GetTouchFinger(tid, 1);
if (!f0 || !f1) return;
float x0 = f0->x * w, x1 = f1->x * w;
float dx = x1 - x0, dy = (f1->y - f0->y) * h;
float dist = std::sqrt(dx * dx + dy * dy);
float centerX = (x0 + x1) * 0.5f;
if (touch_.startDist > 1.0f) {
float span0 = touch_.startHi - touch_.startLo;
float ratio = touch_.startDist / std::max(dist, 1.0f);
float newSpan = std::clamp(span0 * ratio, 0.001f, 1.0f);
float panelW = wfSizeX > 0 ? wfSizeX : static_cast<float>(w);
float panelX = wfPosX;
float midFrac = (touch_.startCenterX - panelX) / panelW;
float midView = touch_.startLo + midFrac * span0;
float panDelta = -(centerX - touch_.startCenterX) / panelW * newSpan;
float newLo = midView - midFrac * newSpan + panDelta;
float newHi = newLo + newSpan;
if (newLo < 0.0f) { newHi -= newLo; newLo = 0.0f; }
if (newHi > 1.0f) { newLo -= (newHi - 1.0f); newHi = 1.0f; }
ui.viewLo = std::clamp(newLo, 0.0f, 1.0f);
ui.viewHi = std::clamp(newHi, 0.0f, 1.0f);
}
}
}
}
} // namespace baudmine

67
src/ui/DisplayPanel.h Normal file
View File

@@ -0,0 +1,67 @@
#pragma once
#include "core/Types.h"
#include "ui/UIState.h"
#include "ui/WaterfallDisplay.h"
#include "ui/SpectrumDisplay.h"
#include "ui/Cursors.h"
#include "ui/Measurements.h"
#include "ui/ColorMap.h"
#include <SDL.h>
#include <vector>
namespace baudmine {
class AudioEngine;
class DisplayPanel {
public:
void renderSpectrum(AudioEngine& audio, UIState& ui,
SpectrumDisplay& specDisplay, Cursors& cursors,
Measurements& measurements);
void renderWaterfall(AudioEngine& audio, UIState& ui,
WaterfallDisplay& waterfall, SpectrumDisplay& specDisplay,
Cursors& cursors, Measurements& measurements,
ColorMap& colorMap);
void renderHoverOverlay(const AudioEngine& audio, const UIState& ui,
const Cursors& cursors, const SpectrumDisplay& specDisplay);
void handleTouch(const SDL_Event& event, UIState& ui, SDL_Window* window);
// Panel geometry (read by Application for layout)
float specPosX = 0, specPosY = 0, specSizeX = 0, specSizeY = 0;
float wfPosX = 0, wfPosY = 0, wfSizeX = 0, wfSizeY = 0;
enum class HoverPanel { None, Spectrum, Waterfall };
HoverPanel hoverPanel = HoverPanel::None;
float hoverWfTimeOff = 0.0f;
float spectrumFrac = 0.35f;
bool draggingSplit = false;
private:
void handleSpectrumInput(AudioEngine& audio, UIState& ui,
SpectrumDisplay& specDisplay, Cursors& cursors,
float posX, float posY, float sizeX, float sizeY);
struct TouchState {
int count = 0;
float startDist = 0.0f;
float startLo = 0.0f;
float startHi = 0.0f;
float startCenterX = 0.0f;
float lastCenterX = 0.0f;
float lastDist = 0.0f;
} touch_;
// Scratch buffers
std::vector<std::vector<float>> wfSpectraScratch_;
std::vector<WaterfallChannelInfo> wfChInfoScratch_;
std::vector<std::vector<float>> allSpectraScratch_;
std::vector<ChannelStyle> stylesScratch_;
};
} // namespace baudmine

View File

@@ -2,8 +2,22 @@
#include <cmath> #include <cmath>
#include <algorithm> #include <algorithm>
#ifdef __EMSCRIPTEN__
#include <GLES2/gl2.h>
#else
#include <GL/gl.h>
#endif
namespace baudmine { namespace baudmine {
// ImGui draw-list callbacks to switch GL blend mode for additive color mixing.
static void setAdditiveBlend(const ImDrawList*, const ImDrawCmd*) {
glBlendFunc(GL_SRC_ALPHA, GL_ONE);
}
static void restoreDefaultBlend(const ImDrawList*, const ImDrawCmd*) {
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}
static float freqToLogFrac(double freq, double minFreq, double maxFreq) { static float freqToLogFrac(double freq, double minFreq, double maxFreq) {
if (freq <= 0 || minFreq <= 0) return 0.0f; if (freq <= 0 || minFreq <= 0) return 0.0f;
double logMin = std::log10(minFreq); double logMin = std::log10(minFreq);
@@ -152,52 +166,75 @@ void SpectrumDisplay::draw(const std::vector<std::vector<float>>& spectra,
} }
} }
// Draw each channel's spectrum. // Build polylines for all channels.
std::vector<ImVec2> points;
int nCh = static_cast<int>(spectra.size()); int nCh = static_cast<int>(spectra.size());
std::vector<std::vector<ImVec2>> allPoints(nCh);
for (int ch = 0; ch < nCh; ++ch) { for (int ch = 0; ch < nCh; ++ch) {
if (spectra[ch].empty()) continue; if (spectra[ch].empty()) continue;
const ChannelStyle& st = (ch < static_cast<int>(styles.size()))
? styles[ch]
: styles.back();
buildPolyline(spectra[ch], minDB, maxDB, buildPolyline(spectra[ch], minDB, maxDB,
isIQ, freqScale, posX, posY, sizeX, sizeY, isIQ, freqScale, posX, posY, sizeX, sizeY,
viewLo, viewHi, points); viewLo, viewHi, allPoints[ch]);
}
// Fill // Draw spectra: all fills first, then all lines on top.
if (fillSpectrum && points.size() >= 2) { // Multi-channel uses additive GL blending so colors mix (green + purple = white).
for (size_t i = 0; i + 1 < points.size(); ++i) { {
ImVec2 tl = points[i]; bool additive = additiveBlend && nCh > 1;
ImVec2 tr = points[i + 1]; // Use lower alpha under additive blend to avoid oversaturation.
ImVec2 bl = {tl.x, posY + sizeY}; constexpr ImU8 kLineAlpha = 180;
ImVec2 br = {tr.x, posY + sizeY}; constexpr ImU8 kFillAlpha = 30;
dl->AddQuadFilled(tl, tr, br, bl, st.fillColor);
if (additive)
dl->AddCallback(setAdditiveBlend, nullptr);
// Pass 1: fills (drawn first so lines sit on top of all fills).
if (fillSpectrum) {
for (int ch = 0; ch < nCh; ++ch) {
const auto& pts = allPoints[ch];
if (pts.size() < 2) continue;
const ChannelStyle& st = (ch < static_cast<int>(styles.size()))
? styles[ch] : styles.back();
ImU32 col = additive
? ((st.fillColor & 0x00FFFFFF) | (static_cast<ImU32>(kFillAlpha) << 24))
: st.fillColor;
for (size_t i = 0; i + 1 < pts.size(); ++i) {
dl->AddQuadFilled(pts[i], pts[i + 1],
{pts[i + 1].x, posY + sizeY},
{pts[i].x, posY + sizeY}, col);
}
} }
} }
// Line // Pass 2: lines.
if (points.size() >= 2) for (int ch = 0; ch < nCh; ++ch) {
dl->AddPolyline(points.data(), static_cast<int>(points.size()), const auto& pts = allPoints[ch];
st.lineColor, ImDrawFlags_None, 1.5f); if (pts.size() < 2) continue;
const ChannelStyle& st = (ch < static_cast<int>(styles.size()))
? styles[ch] : styles.back();
ImU32 col = additive
? ((st.lineColor & 0x00FFFFFF) | (static_cast<ImU32>(kLineAlpha) << 24))
: st.lineColor;
dl->AddPolyline(pts.data(), static_cast<int>(pts.size()),
col, ImDrawFlags_None, 1.5f);
}
if (additive)
dl->AddCallback(restoreDefaultBlend, nullptr);
} }
// Peak hold traces (drawn as dashed-style thin lines above the live spectrum). // Peak hold traces.
if (peakHoldEnable && !peakHold_.empty()) { if (peakHoldEnable && !peakHold_.empty()) {
std::vector<ImVec2> phPoints;
for (int ch = 0; ch < nCh && ch < static_cast<int>(peakHold_.size()); ++ch) { for (int ch = 0; ch < nCh && ch < static_cast<int>(peakHold_.size()); ++ch) {
if (peakHold_[ch].empty()) continue; if (peakHold_[ch].empty()) continue;
const ChannelStyle& st = (ch < static_cast<int>(styles.size())) const ChannelStyle& st = (ch < static_cast<int>(styles.size()))
? styles[ch] : styles.back(); ? styles[ch] : styles.back();
// Use the same line color but dimmer.
ImU32 col = (st.lineColor & 0x00FFFFFF) | 0x90000000; ImU32 col = (st.lineColor & 0x00FFFFFF) | 0x90000000;
buildPolyline(peakHold_[ch], minDB, maxDB, buildPolyline(peakHold_[ch], minDB, maxDB,
isIQ, freqScale, posX, posY, sizeX, sizeY, isIQ, freqScale, posX, posY, sizeX, sizeY,
viewLo, viewHi, points); viewLo, viewHi, phPoints);
if (phPoints.size() >= 2)
if (points.size() >= 2) dl->AddPolyline(phPoints.data(), static_cast<int>(phPoints.size()),
dl->AddPolyline(points.data(), static_cast<int>(points.size()),
col, ImDrawFlags_None, 1.0f); col, ImDrawFlags_None, 1.0f);
} }
} }

View File

@@ -46,6 +46,7 @@ public:
bool showGrid = true; bool showGrid = true;
bool fillSpectrum = false; bool fillSpectrum = false;
bool additiveBlend = true; // additive color mixing for multi-channel
bool peakHoldEnable = false; bool peakHoldEnable = false;
float peakHoldDecay = 20.0f; // dB/second decay rate float peakHoldDecay = 20.0f; // dB/second decay rate

34
src/ui/UIState.h Normal file
View File

@@ -0,0 +1,34 @@
#pragma once
#include "core/Types.h"
#include <imgui.h>
#include <array>
namespace baudmine {
// Shared display state accessed by both ControlPanel and DisplayPanel.
struct UIState {
float minDB = -120.0f;
float maxDB = 0.0f;
FreqScale freqScale = FreqScale::Linear;
float viewLo = 0.0f;
float viewHi = 0.5f;
bool paused = false;
int waterfallChannel = 0;
bool waterfallMultiCh = true;
std::array<bool, kMaxChannels> channelEnabled = {true,true,true,true,true,true,true,true};
// Complementary pairs: colors at indices 0+1, 2+3, 4+5, 6+7 sum to white.
std::array<ImVec4, kMaxChannels> channelColors = {{
{0.20f, 0.90f, 0.20f, 1.0f}, // green
{0.80f, 0.10f, 0.80f, 1.0f}, // purple
{1.00f, 0.55f, 0.00f, 1.0f}, // orange
{0.00f, 0.45f, 1.00f, 1.0f}, // cyan
{1.00f, 0.25f, 0.25f, 1.0f}, // red
{0.00f, 0.75f, 0.75f, 1.0f}, // teal
{1.00f, 1.00f, 0.20f, 1.0f}, // yellow
{0.00f, 0.00f, 0.80f, 1.0f}, // blue
}};
};
} // namespace baudmine