initial commit

This commit is contained in:
2026-03-25 19:46:15 +01:00
commit a513c66503
26 changed files with 2892 additions and 0 deletions

27
src/audio/AudioSource.h Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include <cstddef>
namespace baudline {
// Abstract audio source. All sources deliver interleaved float samples.
// For I/Q data channels()==2: [I0, Q0, I1, Q1, ...].
// For mono real data channels()==1.
class AudioSource {
public:
virtual ~AudioSource() = default;
virtual bool open() = 0;
virtual void close() = 0;
// Read up to `frames` frames into `buffer` (buffer size >= frames * channels()).
// Returns number of frames actually read.
virtual size_t read(float* buffer, size_t frames) = 0;
virtual double sampleRate() const = 0;
virtual int channels() const = 0; // 1 = real, 2 = I/Q
virtual bool isRealTime() const = 0;
virtual bool isEOF() const = 0;
};
} // namespace baudline

156
src/audio/FileSource.cpp Normal file
View File

@@ -0,0 +1,156 @@
#include "audio/FileSource.h"
#include <algorithm>
#include <cstring>
namespace baudline {
FileSource::FileSource(const std::string& path, InputFormat format,
double sampleRate, bool loop)
: path_(path), format_(format), sampleRate_(sampleRate), loop_(loop)
{
if (format_ == InputFormat::WAV) {
channels_ = 0; // determined on open
} else {
channels_ = 2; // I/Q is always 2 channels
}
}
FileSource::~FileSource() {
close();
}
bool FileSource::open() {
close();
eof_ = false;
if (format_ == InputFormat::WAV) {
std::memset(&sfInfo_, 0, sizeof(sfInfo_));
sndFile_ = sf_open(path_.c_str(), SFM_READ, &sfInfo_);
if (!sndFile_) return false;
sampleRate_ = sfInfo_.samplerate;
channels_ = sfInfo_.channels;
return true;
}
// Raw I/Q file
rawFile_.open(path_, std::ios::binary);
if (!rawFile_.is_open()) return false;
rawFile_.seekg(0, std::ios::end);
rawFileSize_ = rawFile_.tellg();
rawFile_.seekg(0, std::ios::beg);
channels_ = 2;
return true;
}
void FileSource::close() {
if (sndFile_) {
sf_close(sndFile_);
sndFile_ = nullptr;
}
if (rawFile_.is_open()) {
rawFile_.close();
}
}
size_t FileSource::read(float* buffer, size_t frames) {
if (eof_) return 0;
size_t got = 0;
switch (format_) {
case InputFormat::WAV: got = readWAV(buffer, frames); break;
case InputFormat::Float32IQ: got = readRawFloat32(buffer, frames); break;
case InputFormat::Int16IQ: got = readRawInt16(buffer, frames); break;
case InputFormat::Uint8IQ: got = readRawUint8(buffer, frames); break;
default: break;
}
if (got < frames) {
if (loop_) {
seek(0.0);
eof_ = false;
// Fill remainder
size_t extra = read(buffer + got * channels_, frames - got);
got += extra;
} else {
eof_ = true;
}
}
return got;
}
void FileSource::seek(double seconds) {
eof_ = false;
if (format_ == InputFormat::WAV && sndFile_) {
sf_seek(sndFile_, static_cast<sf_count_t>(seconds * sampleRate_), SEEK_SET);
} else if (rawFile_.is_open()) {
size_t bytesPerFrame = 0;
switch (format_) {
case InputFormat::Float32IQ: bytesPerFrame = 2 * sizeof(float); break;
case InputFormat::Int16IQ: bytesPerFrame = 2 * sizeof(int16_t); break;
case InputFormat::Uint8IQ: bytesPerFrame = 2 * sizeof(uint8_t); break;
default: break;
}
auto pos = static_cast<std::streamoff>(seconds * sampleRate_ * bytesPerFrame);
rawFile_.clear();
rawFile_.seekg(pos);
}
}
double FileSource::duration() const {
if (format_ == InputFormat::WAV && sfInfo_.samplerate > 0) {
return static_cast<double>(sfInfo_.frames) / sfInfo_.samplerate;
}
if (rawFileSize_ > 0) {
size_t bytesPerFrame = 0;
switch (format_) {
case InputFormat::Float32IQ: bytesPerFrame = 2 * sizeof(float); break;
case InputFormat::Int16IQ: bytesPerFrame = 2 * sizeof(int16_t); break;
case InputFormat::Uint8IQ: bytesPerFrame = 2 * sizeof(uint8_t); break;
default: return -1.0;
}
size_t totalFrames = rawFileSize_ / bytesPerFrame;
return static_cast<double>(totalFrames) / sampleRate_;
}
return -1.0;
}
// ── Format-specific readers ──────────────────────────────────────────────────
size_t FileSource::readWAV(float* buffer, size_t frames) {
if (!sndFile_) return 0;
sf_count_t got = sf_readf_float(sndFile_, buffer, frames);
return (got > 0) ? static_cast<size_t>(got) : 0;
}
size_t FileSource::readRawFloat32(float* buffer, size_t frames) {
size_t samples = frames * 2;
rawFile_.read(reinterpret_cast<char*>(buffer), samples * sizeof(float));
size_t bytesRead = rawFile_.gcount();
return bytesRead / (2 * sizeof(float));
}
size_t FileSource::readRawInt16(float* buffer, size_t frames) {
size_t samples = frames * 2;
std::vector<int16_t> tmp(samples);
rawFile_.read(reinterpret_cast<char*>(tmp.data()), samples * sizeof(int16_t));
size_t bytesRead = rawFile_.gcount();
size_t samplesRead = bytesRead / sizeof(int16_t);
for (size_t i = 0; i < samplesRead; ++i)
buffer[i] = tmp[i] / 32768.0f;
return samplesRead / 2;
}
size_t FileSource::readRawUint8(float* buffer, size_t frames) {
size_t samples = frames * 2;
std::vector<uint8_t> tmp(samples);
rawFile_.read(reinterpret_cast<char*>(tmp.data()), samples * sizeof(uint8_t));
size_t bytesRead = rawFile_.gcount();
size_t samplesRead = bytesRead / sizeof(uint8_t);
// RTL-SDR style: center at 127.5, scale to [-1, 1]
for (size_t i = 0; i < samplesRead; ++i)
buffer[i] = (tmp[i] - 127.5f) / 127.5f;
return samplesRead / 2;
}
} // namespace baudline

58
src/audio/FileSource.h Normal file
View File

@@ -0,0 +1,58 @@
#pragma once
#include "audio/AudioSource.h"
#include "core/Types.h"
#include <sndfile.h>
#include <fstream>
#include <string>
#include <vector>
namespace baudline {
// Reads WAV files (via libsndfile) and raw I/Q files (float32, int16, uint8).
class FileSource : public AudioSource {
public:
// For WAV files: format is auto-detected, sampleRate/channels from file header.
// For raw I/Q: user must specify format and sampleRate.
FileSource(const std::string& path, InputFormat format = InputFormat::WAV,
double sampleRate = 48000.0, bool loop = false);
~FileSource() override;
bool open() override;
void close() override;
size_t read(float* buffer, size_t frames) override;
double sampleRate() const override { return sampleRate_; }
int channels() const override { return channels_; }
bool isRealTime() const override { return false; }
bool isEOF() const override { return eof_; }
// Seek to a position (seconds).
void seek(double seconds);
// File duration in seconds (-1 if unknown, e.g. raw files without known size).
double duration() const;
private:
size_t readWAV(float* buffer, size_t frames);
size_t readRawFloat32(float* buffer, size_t frames);
size_t readRawInt16(float* buffer, size_t frames);
size_t readRawUint8(float* buffer, size_t frames);
std::string path_;
InputFormat format_;
double sampleRate_;
int channels_ = 2; // I/Q default
bool loop_;
bool eof_ = false;
// WAV via libsndfile
SNDFILE* sndFile_ = nullptr;
SF_INFO sfInfo_{};
// Raw I/Q files
std::ifstream rawFile_;
size_t rawFileSize_ = 0;
};
} // namespace baudline

View File

@@ -0,0 +1,114 @@
#include "audio/PortAudioSource.h"
#include <cstdio>
#include <cstring>
namespace baudline {
static bool sPaInitialized = false;
static void ensurePaInit() {
if (!sPaInitialized) {
Pa_Initialize();
sPaInitialized = true;
}
}
PortAudioSource::PortAudioSource(double sampleRate, int channels,
int deviceIndex, int framesPerBuffer)
: sampleRate_(sampleRate)
, channels_(channels)
, deviceIndex_(deviceIndex)
, framesPerBuffer_(framesPerBuffer)
{
ensurePaInit();
size_t ringSize = static_cast<size_t>(sampleRate * channels * 2); // ~2 seconds
ringBuf_ = std::make_unique<RingBuffer<float>>(ringSize);
}
PortAudioSource::~PortAudioSource() {
close();
}
bool PortAudioSource::open() {
if (opened_) return true;
PaStreamParameters params{};
if (deviceIndex_ < 0) {
params.device = Pa_GetDefaultInputDevice();
} else {
params.device = deviceIndex_;
}
if (params.device == paNoDevice) {
std::fprintf(stderr, "PortAudio: no input device available\n");
return false;
}
const PaDeviceInfo* info = Pa_GetDeviceInfo(params.device);
if (!info) return false;
params.channelCount = channels_;
params.sampleFormat = paFloat32;
params.suggestedLatency = info->defaultLowInputLatency;
params.hostApiSpecificStreamInfo = nullptr;
PaError err = Pa_OpenStream(&stream_, &params, nullptr,
sampleRate_, framesPerBuffer_,
paClipOff, paCallback, this);
if (err != paNoError) {
std::fprintf(stderr, "PortAudio open error: %s\n", Pa_GetErrorText(err));
return false;
}
err = Pa_StartStream(stream_);
if (err != paNoError) {
std::fprintf(stderr, "PortAudio start error: %s\n", Pa_GetErrorText(err));
Pa_CloseStream(stream_);
stream_ = nullptr;
return false;
}
opened_ = true;
return true;
}
void PortAudioSource::close() {
if (stream_) {
Pa_StopStream(stream_);
Pa_CloseStream(stream_);
stream_ = nullptr;
}
opened_ = false;
}
size_t PortAudioSource::read(float* buffer, size_t frames) {
return ringBuf_->read(buffer, frames * channels_) / channels_;
}
int PortAudioSource::paCallback(const void* input, void* /*output*/,
unsigned long frameCount,
const PaStreamCallbackTimeInfo* /*timeInfo*/,
PaStreamCallbackFlags /*statusFlags*/,
void* userData) {
auto* self = static_cast<PortAudioSource*>(userData);
if (input) {
const auto* in = static_cast<const float*>(input);
self->ringBuf_->write(in, frameCount * self->channels_);
}
return paContinue;
}
std::vector<PortAudioSource::DeviceInfo> PortAudioSource::listInputDevices() {
ensurePaInit();
std::vector<DeviceInfo> devices;
int count = Pa_GetDeviceCount();
for (int i = 0; i < count; ++i) {
const PaDeviceInfo* info = Pa_GetDeviceInfo(i);
if (info && info->maxInputChannels > 0) {
devices.push_back({i, info->name, info->maxInputChannels,
info->defaultSampleRate});
}
}
return devices;
}
} // namespace baudline

View File

@@ -0,0 +1,54 @@
#pragma once
#include "audio/AudioSource.h"
#include "core/RingBuffer.h"
#include <portaudio.h>
#include <memory>
#include <string>
namespace baudline {
class PortAudioSource : public AudioSource {
public:
// deviceIndex = -1 for default input device
PortAudioSource(double sampleRate = 48000.0, int channels = 1,
int deviceIndex = -1, int framesPerBuffer = 512);
~PortAudioSource() override;
bool open() override;
void close() override;
size_t read(float* buffer, size_t frames) override;
double sampleRate() const override { return sampleRate_; }
int channels() const override { return channels_; }
bool isRealTime() const override { return true; }
bool isEOF() const override { return false; }
// List available input devices (for UI enumeration).
struct DeviceInfo {
int index;
std::string name;
int maxInputChannels;
double defaultSampleRate;
};
static std::vector<DeviceInfo> listInputDevices();
private:
static int paCallback(const void* input, void* output,
unsigned long frameCount,
const PaStreamCallbackTimeInfo* timeInfo,
PaStreamCallbackFlags statusFlags,
void* userData);
double sampleRate_;
int channels_;
int deviceIndex_;
int framesPerBuffer_;
PaStream* stream_ = nullptr;
bool opened_ = false;
// Ring buffer large enough for ~1 second of audio
std::unique_ptr<RingBuffer<float>> ringBuf_;
};
} // namespace baudline

100
src/core/RingBuffer.h Normal file
View File

@@ -0,0 +1,100 @@
#pragma once
#include <atomic>
#include <cstddef>
#include <cstring>
#include <vector>
namespace baudline {
// Single-producer single-consumer lock-free ring buffer for audio data.
// Producer: audio callback thread. Consumer: main/render thread.
template <typename T>
class RingBuffer {
public:
explicit RingBuffer(size_t capacity)
: capacity_(nextPow2(capacity))
, mask_(capacity_ - 1)
, buf_(capacity_)
{}
// Returns number of items available to read.
size_t available() const {
return writePos_.load(std::memory_order_acquire)
- readPos_.load(std::memory_order_relaxed);
}
size_t freeSpace() const {
return capacity_ - available();
}
// Write up to `count` items. Returns number actually written.
size_t write(const T* data, size_t count) {
const size_t wp = writePos_.load(std::memory_order_relaxed);
const size_t rp = readPos_.load(std::memory_order_acquire);
const size_t free = capacity_ - (wp - rp);
if (count > free) count = free;
if (count == 0) return 0;
const size_t idx = wp & mask_;
const size_t tail = capacity_ - idx;
if (count <= tail) {
std::memcpy(&buf_[idx], data, count * sizeof(T));
} else {
std::memcpy(&buf_[idx], data, tail * sizeof(T));
std::memcpy(&buf_[0], data + tail, (count - tail) * sizeof(T));
}
writePos_.store(wp + count, std::memory_order_release);
return count;
}
// Read up to `count` items. Returns number actually read.
size_t read(T* data, size_t count) {
const size_t rp = readPos_.load(std::memory_order_relaxed);
const size_t wp = writePos_.load(std::memory_order_acquire);
const size_t avail = wp - rp;
if (count > avail) count = avail;
if (count == 0) return 0;
const size_t idx = rp & mask_;
const size_t tail = capacity_ - idx;
if (count <= tail) {
std::memcpy(data, &buf_[idx], count * sizeof(T));
} else {
std::memcpy(data, &buf_[idx], tail * sizeof(T));
std::memcpy(data + tail, &buf_[0], (count - tail) * sizeof(T));
}
readPos_.store(rp + count, std::memory_order_release);
return count;
}
// Discard up to `count` items without copying.
size_t discard(size_t count) {
const size_t rp = readPos_.load(std::memory_order_relaxed);
const size_t wp = writePos_.load(std::memory_order_acquire);
const size_t avail = wp - rp;
if (count > avail) count = avail;
readPos_.store(rp + count, std::memory_order_release);
return count;
}
void reset() {
readPos_.store(0, std::memory_order_relaxed);
writePos_.store(0, std::memory_order_relaxed);
}
private:
static size_t nextPow2(size_t v) {
size_t p = 1;
while (p < v) p <<= 1;
return p;
}
const size_t capacity_;
const size_t mask_;
std::vector<T> buf_;
alignas(64) std::atomic<size_t> writePos_{0};
alignas(64) std::atomic<size_t> readPos_{0};
};
} // namespace baudline

118
src/core/Types.h Normal file
View File

@@ -0,0 +1,118 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <complex>
#include <string>
#include <vector>
namespace baudline {
// ── FFT configuration ────────────────────────────────────────────────────────
constexpr int kMinFFTSize = 256;
constexpr int kMaxFFTSize = 65536;
constexpr int kDefaultFFTSize = 4096;
constexpr int kWaterfallHistory = 2048;
// ── Enumerations ─────────────────────────────────────────────────────────────
enum class WindowType {
Rectangular,
Hann,
Hamming,
Blackman,
BlackmanHarris,
Kaiser,
FlatTop,
Count
};
inline const char* windowName(WindowType w) {
switch (w) {
case WindowType::Rectangular: return "Rectangular";
case WindowType::Hann: return "Hann";
case WindowType::Hamming: return "Hamming";
case WindowType::Blackman: return "Blackman";
case WindowType::BlackmanHarris: return "Blackman-Harris";
case WindowType::Kaiser: return "Kaiser";
case WindowType::FlatTop: return "Flat Top";
default: return "Unknown";
}
}
enum class FreqScale {
Linear,
Logarithmic
};
enum class ColorMapType {
Magma,
Viridis,
Inferno,
Plasma,
Grayscale,
Count
};
inline const char* colorMapName(ColorMapType c) {
switch (c) {
case ColorMapType::Magma: return "Magma";
case ColorMapType::Viridis: return "Viridis";
case ColorMapType::Inferno: return "Inferno";
case ColorMapType::Plasma: return "Plasma";
case ColorMapType::Grayscale: return "Grayscale";
default: return "Unknown";
}
}
enum class InputFormat {
Float32IQ,
Int16IQ,
Uint8IQ,
WAV,
PortAudio
};
inline const char* inputFormatName(InputFormat f) {
switch (f) {
case InputFormat::Float32IQ: return "Float32 I/Q";
case InputFormat::Int16IQ: return "Int16 I/Q";
case InputFormat::Uint8IQ: return "Uint8 I/Q";
case InputFormat::WAV: return "WAV File";
case InputFormat::PortAudio: return "PortAudio";
default: return "Unknown";
}
}
// ── Spectrum data ────────────────────────────────────────────────────────────
struct SpectrumLine {
std::vector<float> magnitudeDB; // power in dB, length = fftSize/2 (real) or fftSize (IQ)
double centerFreq; // Hz (0 for real signals)
double bandwidth; // Hz (= sampleRate for IQ, sampleRate/2 for real)
};
constexpr int kMaxChannels = 8;
struct AnalyzerSettings {
int fftSize = kDefaultFFTSize;
float overlap = 0.5f; // 0.0 0.875
WindowType window = WindowType::BlackmanHarris;
float kaiserBeta = 9.0f;
bool isIQ = false; // true → complex input (2-ch interleaved)
int numChannels = 1; // real channels (ignored when isIQ)
double sampleRate = 48000.0;
int averaging = 1; // number of spectra to average (1 = none)
// Effective input channel count (for buffer sizing / deinterleaving).
int inputChannels() const { return isIQ ? 2 : numChannels; }
};
// ── Color ────────────────────────────────────────────────────────────────────
struct Color3 {
uint8_t r, g, b;
};
} // namespace baudline

85
src/dsp/FFTProcessor.cpp Normal file
View File

@@ -0,0 +1,85 @@
#include "dsp/FFTProcessor.h"
#include <cmath>
#include <algorithm>
namespace baudline {
FFTProcessor::FFTProcessor() = default;
FFTProcessor::~FFTProcessor() {
destroyPlans();
}
void FFTProcessor::destroyPlans() {
if (realPlan_) { fftwf_destroy_plan(realPlan_); realPlan_ = nullptr; }
if (realIn_) { fftwf_free(realIn_); realIn_ = nullptr; }
if (realOut_) { fftwf_free(realOut_); realOut_ = nullptr; }
if (cplxPlan_) { fftwf_destroy_plan(cplxPlan_); cplxPlan_ = nullptr; }
if (cplxIn_) { fftwf_free(cplxIn_); cplxIn_ = nullptr; }
if (cplxOut_) { fftwf_free(cplxOut_); cplxOut_ = nullptr; }
}
void FFTProcessor::configure(int fftSize, bool complexInput) {
if (fftSize == fftSize_ && complexInput == complexInput_) return;
destroyPlans();
fftSize_ = fftSize;
complexInput_ = complexInput;
if (complexInput_) {
cplxIn_ = fftwf_alloc_complex(fftSize_);
cplxOut_ = fftwf_alloc_complex(fftSize_);
cplxPlan_ = fftwf_plan_dft_1d(fftSize_, cplxIn_, cplxOut_,
FFTW_FORWARD, FFTW_ESTIMATE);
} else {
realIn_ = fftwf_alloc_real(fftSize_);
realOut_ = fftwf_alloc_complex(fftSize_ / 2 + 1);
realPlan_ = fftwf_plan_dft_r2c_1d(fftSize_, realIn_, realOut_, FFTW_ESTIMATE);
}
}
void FFTProcessor::processReal(const float* input, std::vector<float>& outputDB) {
const int N = fftSize_;
const int bins = N / 2 + 1;
outputDB.resize(bins);
std::copy(input, input + N, realIn_);
fftwf_execute(realPlan_);
const float scale = 1.0f / N;
for (int i = 0; i < bins; ++i) {
float re = realOut_[i][0] * scale;
float im = realOut_[i][1] * scale;
float mag2 = re * re + im * im;
// Power in dB, floor at -200 dB
outputDB[i] = (mag2 > 1e-20f) ? 10.0f * std::log10(mag2) : -200.0f;
}
}
void FFTProcessor::processComplex(const float* inputIQ, std::vector<float>& outputDB) {
const int N = fftSize_;
outputDB.resize(N);
// Copy interleaved I/Q into FFTW complex array
for (int i = 0; i < N; ++i) {
cplxIn_[i][0] = inputIQ[2 * i];
cplxIn_[i][1] = inputIQ[2 * i + 1];
}
fftwf_execute(cplxPlan_);
// FFT-shift: reorder so DC is in center.
// FFTW output: [0, 1, ..., N/2-1, -N/2, ..., -1]
// Shifted: [-N/2, ..., -1, 0, 1, ..., N/2-1]
const float scale = 1.0f / N;
const int half = N / 2;
for (int i = 0; i < N; ++i) {
int src = (i + half) % N;
float re = cplxOut_[src][0] * scale;
float im = cplxOut_[src][1] * scale;
float mag2 = re * re + im * im;
outputDB[i] = (mag2 > 1e-20f) ? 10.0f * std::log10(mag2) : -200.0f;
}
}
} // namespace baudline

56
src/dsp/FFTProcessor.h Normal file
View File

@@ -0,0 +1,56 @@
#pragma once
#include "core/Types.h"
#include <fftw3.h>
#include <vector>
#include <complex>
namespace baudline {
// Wraps FFTW for real→complex and complex→complex transforms.
// Produces magnitude output in dB.
class FFTProcessor {
public:
FFTProcessor();
~FFTProcessor();
FFTProcessor(const FFTProcessor&) = delete;
FFTProcessor& operator=(const FFTProcessor&) = delete;
// Reconfigure for a new FFT size and mode. Rebuilds FFTW plans.
void configure(int fftSize, bool complexInput);
int fftSize() const { return fftSize_; }
bool isComplex() const { return complexInput_; }
int outputBins() const { return complexInput_ ? fftSize_ : fftSize_ / 2 + 1; }
int spectrumSize() const { return complexInput_ ? fftSize_ : fftSize_ / 2 + 1; }
// Process windowed real samples → magnitude dB spectrum.
// `input` must have fftSize_ elements.
// `outputDB` will be resized to spectrumSize().
void processReal(const float* input, std::vector<float>& outputDB);
// Process windowed I/Q samples → magnitude dB spectrum.
// `inputIQ` is interleaved [I0,Q0,I1,Q1,...], fftSize_*2 floats.
// `outputDB` will be resized to spectrumSize().
// Output is FFT-shifted so DC is in the center.
void processComplex(const float* inputIQ, std::vector<float>& outputDB);
private:
int fftSize_ = 0;
bool complexInput_ = false;
// Real FFT
float* realIn_ = nullptr;
fftwf_complex* realOut_ = nullptr;
fftwf_plan realPlan_ = nullptr;
// Complex FFT
fftwf_complex* cplxIn_ = nullptr;
fftwf_complex* cplxOut_ = nullptr;
fftwf_plan cplxPlan_ = nullptr;
void destroyPlans();
};
} // namespace baudline

View File

@@ -0,0 +1,170 @@
#include "dsp/SpectrumAnalyzer.h"
#include <algorithm>
#include <cmath>
#include <cstring>
namespace baudline {
SpectrumAnalyzer::SpectrumAnalyzer() {
// Force sizeChanged=true on first configure by setting fftSize to 0.
settings_.fftSize = 0;
configure(AnalyzerSettings{});
}
void SpectrumAnalyzer::configure(const AnalyzerSettings& settings) {
bool sizeChanged = settings.fftSize != settings_.fftSize ||
settings.isIQ != settings_.isIQ ||
settings.numChannels != settings_.numChannels;
settings_ = settings;
fft_.configure(settings_.fftSize, settings_.isIQ);
WindowFunctions::generate(settings_.window, settings_.fftSize, window_,
settings_.kaiserBeta);
windowGain_ = WindowFunctions::coherentGain(window_);
int inCh = settings_.inputChannels();
hopSize_ = static_cast<size_t>(settings_.fftSize * (1.0f - settings_.overlap));
if (hopSize_ < 1) hopSize_ = 1;
if (sizeChanged) {
accumBuf_.assign(settings_.fftSize * inCh, 0.0f);
accumPos_ = 0;
int nSpec = settings_.isIQ ? 1 : settings_.numChannels;
int specSz = fft_.spectrumSize();
channelSpectra_.assign(nSpec, std::vector<float>(specSz, -200.0f));
channelWaterfalls_.assign(nSpec, {});
avgAccum_.assign(nSpec, std::vector<float>(specSz, 0.0f));
avgCount_ = 0;
newSpectrumReady_ = false;
}
}
void SpectrumAnalyzer::pushSamples(const float* data, size_t frames) {
int inCh = settings_.inputChannels();
size_t totalSamples = frames * inCh;
size_t bufLen = static_cast<size_t>(settings_.fftSize) * inCh;
const float* ptr = data;
size_t remaining = totalSamples;
newSpectrumReady_ = false;
while (remaining > 0) {
size_t space = bufLen - accumPos_;
size_t toCopy = std::min(remaining, space);
std::memcpy(accumBuf_.data() + accumPos_, ptr, toCopy * sizeof(float));
accumPos_ += toCopy;
ptr += toCopy;
remaining -= toCopy;
if (accumPos_ >= bufLen) {
processBlock();
// Shift by hopSize for overlap
size_t hopSamples = hopSize_ * inCh;
size_t keep = bufLen - hopSamples;
std::memmove(accumBuf_.data(), accumBuf_.data() + hopSamples,
keep * sizeof(float));
accumPos_ = keep;
}
}
}
void SpectrumAnalyzer::processBlock() {
int N = settings_.fftSize;
int inCh = settings_.inputChannels();
int nSpec = static_cast<int>(channelSpectra_.size());
int specSz = fft_.spectrumSize();
// Compute per-channel spectra.
std::vector<std::vector<float>> tempDBs(nSpec);
if (settings_.isIQ) {
// I/Q: treat the 2 interleaved channels as one complex signal.
std::vector<float> windowed(N * 2);
for (int i = 0; i < N; ++i) {
windowed[2 * i] = accumBuf_[2 * i] * window_[i];
windowed[2 * i + 1] = accumBuf_[2 * i + 1] * window_[i];
}
fft_.processComplex(windowed.data(), tempDBs[0]);
} else {
// Real: deinterleave and FFT each channel independently.
std::vector<float> chanBuf(N);
for (int ch = 0; ch < nSpec; ++ch) {
// Deinterleave channel `ch` from the accumulation buffer.
for (int i = 0; i < N; ++i)
chanBuf[i] = accumBuf_[i * inCh + ch];
WindowFunctions::apply(window_, chanBuf.data(), N);
fft_.processReal(chanBuf.data(), tempDBs[ch]);
}
}
// Correct for window gain.
float correction = -20.0f * std::log10(windowGain_ > 0 ? windowGain_ : 1.0f);
for (auto& db : tempDBs)
for (float& v : db)
v += correction;
// Averaging.
if (settings_.averaging > 1) {
if (static_cast<int>(avgAccum_[0].size()) != specSz) {
for (auto& a : avgAccum_) a.assign(specSz, 0.0f);
avgCount_ = 0;
}
for (int ch = 0; ch < nSpec; ++ch)
for (int i = 0; i < specSz; ++i)
avgAccum_[ch][i] += tempDBs[ch][i];
avgCount_++;
if (avgCount_ >= settings_.averaging) {
for (int ch = 0; ch < nSpec; ++ch)
for (int i = 0; i < specSz; ++i)
tempDBs[ch][i] = avgAccum_[ch][i] / avgCount_;
for (auto& a : avgAccum_) a.assign(specSz, 0.0f);
avgCount_ = 0;
} else {
return;
}
}
// Store results.
for (int ch = 0; ch < nSpec; ++ch) {
channelSpectra_[ch] = tempDBs[ch];
channelWaterfalls_[ch].push_back(tempDBs[ch]);
if (channelWaterfalls_[ch].size() > kWaterfallHistory)
channelWaterfalls_[ch].pop_front();
}
newSpectrumReady_ = true;
}
std::pair<int, float> SpectrumAnalyzer::findPeak(int ch) const {
if (ch < 0 || ch >= static_cast<int>(channelSpectra_.size()) ||
channelSpectra_[ch].empty())
return {0, -200.0f};
const auto& spec = channelSpectra_[ch];
auto it = std::max_element(spec.begin(), spec.end());
int idx = static_cast<int>(std::distance(spec.begin(), it));
return {idx, *it};
}
double SpectrumAnalyzer::binToFreq(int bin) const {
double sr = settings_.sampleRate;
int N = settings_.fftSize;
if (settings_.isIQ) {
return -sr / 2.0 + (static_cast<double>(bin) / N) * sr;
} else {
return (static_cast<double>(bin) / N) * sr;
}
}
void SpectrumAnalyzer::clearHistory() {
for (auto& w : channelWaterfalls_) w.clear();
newSpectrumReady_ = false;
}
} // namespace baudline

View File

@@ -0,0 +1,83 @@
#pragma once
#include "core/Types.h"
#include "dsp/FFTProcessor.h"
#include "dsp/WindowFunctions.h"
#include <deque>
#include <vector>
namespace baudline {
// Manages the DSP pipeline: accumulation with overlap, windowing, FFT,
// averaging, and waterfall history.
//
// Supports three modes:
// - Mono real (numChannels=1, isIQ=false): 1 real FFT → 1 spectrum
// - Multi-ch real (numChannels>1, isIQ=false): N real FFTs → N spectra
// - I/Q complex (isIQ=true): 1 complex FFT → 1 spectrum
class SpectrumAnalyzer {
public:
SpectrumAnalyzer();
void configure(const AnalyzerSettings& settings);
const AnalyzerSettings& settings() const { return settings_; }
// Feed raw interleaved audio samples.
// `frames` = number of sample frames (1 frame = inputChannels() samples).
void pushSamples(const float* data, size_t frames);
// Returns true if a new spectrum line is available since last call.
bool hasNewSpectrum() const { return newSpectrumReady_; }
// Number of independent spectra (1 for mono/IQ, numChannels for multi-ch).
int numSpectra() const { return static_cast<int>(channelSpectra_.size()); }
// Per-channel spectra (dB magnitudes).
const std::vector<float>& channelSpectrum(int ch) const { return channelSpectra_[ch]; }
// Convenience: first channel spectrum (backward compat / primary).
const std::vector<float>& currentSpectrum() const { return channelSpectra_[0]; }
// All channel spectra.
const std::vector<std::vector<float>>& allSpectra() const { return channelSpectra_; }
// Number of output bins (per channel).
int spectrumSize() const { return fft_.spectrumSize(); }
// Peak detection on a given channel.
std::pair<int, float> findPeak(int ch = 0) const;
// Get frequency for a given bin index.
double binToFreq(int bin) const;
void clearHistory();
// Waterfall history for a given channel (most recent = back).
const std::deque<std::vector<float>>& waterfallHistory(int ch = 0) const {
return channelWaterfalls_[ch];
}
private:
void processBlock();
AnalyzerSettings settings_;
FFTProcessor fft_;
std::vector<float> window_;
float windowGain_ = 1.0f;
// Accumulation buffer (interleaved, length = fftSize * inputChannels)
std::vector<float> accumBuf_;
size_t accumPos_ = 0;
size_t hopSize_ = 0;
// Per-channel averaging
std::vector<std::vector<float>> avgAccum_;
int avgCount_ = 0;
// Per-channel output
std::vector<std::vector<float>> channelSpectra_;
std::vector<std::deque<std::vector<float>>> channelWaterfalls_;
bool newSpectrumReady_ = false;
};
} // namespace baudline

100
src/dsp/WindowFunctions.cpp Normal file
View File

@@ -0,0 +1,100 @@
#include "dsp/WindowFunctions.h"
#include <cmath>
#include <numeric>
namespace baudline {
static constexpr double kPi = 3.14159265358979323846;
void WindowFunctions::generate(WindowType type, int size, std::vector<float>& out,
float kaiserBeta) {
out.resize(size);
switch (type) {
case WindowType::Rectangular: rectangular(size, out); break;
case WindowType::Hann: hann(size, out); break;
case WindowType::Hamming: hamming(size, out); break;
case WindowType::Blackman: blackman(size, out); break;
case WindowType::BlackmanHarris: blackmanHarris(size, out); break;
case WindowType::Kaiser: kaiser(size, out, kaiserBeta); break;
case WindowType::FlatTop: flatTop(size, out); break;
default: rectangular(size, out); break;
}
}
void WindowFunctions::apply(const std::vector<float>& window, float* data, int size) {
for (int i = 0; i < size; ++i)
data[i] *= window[i];
}
float WindowFunctions::coherentGain(const std::vector<float>& window) {
if (window.empty()) return 1.0f;
double sum = 0.0;
for (float w : window) sum += w;
return static_cast<float>(sum / window.size());
}
// ── Window implementations ───────────────────────────────────────────────────
void WindowFunctions::rectangular(int N, std::vector<float>& w) {
for (int i = 0; i < N; ++i)
w[i] = 1.0f;
}
void WindowFunctions::hann(int N, std::vector<float>& w) {
for (int i = 0; i < N; ++i)
w[i] = static_cast<float>(0.5 * (1.0 - std::cos(2.0 * kPi * i / (N - 1))));
}
void WindowFunctions::hamming(int N, std::vector<float>& w) {
for (int i = 0; i < N; ++i)
w[i] = static_cast<float>(0.54 - 0.46 * std::cos(2.0 * kPi * i / (N - 1)));
}
void WindowFunctions::blackman(int N, std::vector<float>& w) {
for (int i = 0; i < N; ++i) {
double x = 2.0 * kPi * i / (N - 1);
w[i] = static_cast<float>(0.42 - 0.5 * std::cos(x) + 0.08 * std::cos(2.0 * x));
}
}
void WindowFunctions::blackmanHarris(int N, std::vector<float>& w) {
constexpr double a0 = 0.35875, a1 = 0.48829, a2 = 0.14128, a3 = 0.01168;
for (int i = 0; i < N; ++i) {
double x = 2.0 * kPi * i / (N - 1);
w[i] = static_cast<float>(a0 - a1 * std::cos(x)
+ a2 * std::cos(2.0 * x)
- a3 * std::cos(3.0 * x));
}
}
void WindowFunctions::kaiser(int N, std::vector<float>& w, float beta) {
double denom = besselI0(beta);
for (int i = 0; i < N; ++i) {
double t = 2.0 * i / (N - 1) - 1.0;
w[i] = static_cast<float>(besselI0(beta * std::sqrt(1.0 - t * t)) / denom);
}
}
void WindowFunctions::flatTop(int N, std::vector<float>& w) {
constexpr double a0 = 0.21557895, a1 = 0.41663158, a2 = 0.277263158;
constexpr double a3 = 0.083578947, a4 = 0.006947368;
for (int i = 0; i < N; ++i) {
double x = 2.0 * kPi * i / (N - 1);
w[i] = static_cast<float>(a0 - a1 * std::cos(x) + a2 * std::cos(2.0 * x)
- a3 * std::cos(3.0 * x) + a4 * std::cos(4.0 * x));
}
}
// Modified Bessel function of the first kind, order 0.
double WindowFunctions::besselI0(double x) {
double sum = 1.0;
double term = 1.0;
for (int k = 1; k < 30; ++k) {
term *= (x / (2.0 * k)) * (x / (2.0 * k));
sum += term;
if (term < 1e-12 * sum) break;
}
return sum;
}
} // namespace baudline

32
src/dsp/WindowFunctions.h Normal file
View File

@@ -0,0 +1,32 @@
#pragma once
#include "core/Types.h"
#include <vector>
namespace baudline {
class WindowFunctions {
public:
// Fill `out` with the window coefficients for the given type and size.
static void generate(WindowType type, int size, std::vector<float>& out,
float kaiserBeta = 9.0f);
// Apply window in-place: data[i] *= window[i].
static void apply(const std::vector<float>& window, float* data, int size);
// Coherent gain of the window (sum / N), used for amplitude correction.
static float coherentGain(const std::vector<float>& window);
private:
static void rectangular(int N, std::vector<float>& w);
static void hann(int N, std::vector<float>& w);
static void hamming(int N, std::vector<float>& w);
static void blackman(int N, std::vector<float>& w);
static void blackmanHarris(int N, std::vector<float>& w);
static void kaiser(int N, std::vector<float>& w, float beta);
static void flatTop(int N, std::vector<float>& w);
static double besselI0(double x);
};
} // namespace baudline

15
src/main.cpp Normal file
View File

@@ -0,0 +1,15 @@
#include "ui/Application.h"
#include <cstdio>
int main(int argc, char** argv) {
baudline::Application app;
if (!app.init(argc, argv)) {
std::fprintf(stderr, "Failed to initialize application\n");
return 1;
}
app.run();
app.shutdown();
return 0;
}

718
src/ui/Application.cpp Normal file
View 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
View 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 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<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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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