commit a513c66503fd614afe45aa33e883170075b24361 Author: ericek111 Date: Wed Mar 25 19:46:15 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd313a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/build/ +/build_arch/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..2145d81 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,81 @@ +cmake_minimum_required(VERSION 3.16) +project(baudline VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# Build type defaults +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() +set(CMAKE_CXX_FLAGS_RELEASE "-O3 -march=native -DNDEBUG") +set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -fsanitize=address,undefined") + +# ── Dependencies ────────────────────────────────────────────────────────────── + +find_package(PkgConfig REQUIRED) +find_package(OpenGL REQUIRED) + +pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) +pkg_check_modules(FFTW3F REQUIRED IMPORTED_TARGET fftw3f) +pkg_check_modules(PORTAUDIO REQUIRED IMPORTED_TARGET portaudio-2.0) +pkg_check_modules(SNDFILE REQUIRED IMPORTED_TARGET sndfile) + +# ── ImGui via FetchContent ──────────────────────────────────────────────────── + +include(FetchContent) +FetchContent_Declare( + imgui + GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG v1.91.8 +) +FetchContent_MakeAvailable(imgui) + +add_library(imgui STATIC + ${imgui_SOURCE_DIR}/imgui.cpp + ${imgui_SOURCE_DIR}/imgui_demo.cpp + ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp + ${imgui_SOURCE_DIR}/imgui_widgets.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl2.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp +) +target_include_directories(imgui PUBLIC + ${imgui_SOURCE_DIR} + ${imgui_SOURCE_DIR}/backends +) +target_link_libraries(imgui PUBLIC PkgConfig::SDL2 OpenGL::GL) + +# ── Application ─────────────────────────────────────────────────────────────── + +set(SOURCES + src/main.cpp + src/dsp/WindowFunctions.cpp + src/dsp/FFTProcessor.cpp + src/dsp/SpectrumAnalyzer.cpp + src/audio/PortAudioSource.cpp + src/audio/FileSource.cpp + src/ui/ColorMap.cpp + src/ui/WaterfallDisplay.cpp + src/ui/SpectrumDisplay.cpp + src/ui/Cursors.cpp + src/ui/Application.cpp +) + +add_executable(baudline ${SOURCES}) +target_include_directories(baudline PRIVATE src) +target_link_libraries(baudline PRIVATE + imgui + PkgConfig::SDL2 + PkgConfig::FFTW3F + PkgConfig::PORTAUDIO + PkgConfig::SNDFILE + OpenGL::GL + pthread +) + +# Link math library on Unix +if(UNIX) + target_link_libraries(baudline PRIVATE m) +endif() diff --git a/src/audio/AudioSource.h b/src/audio/AudioSource.h new file mode 100644 index 0000000..4b8312c --- /dev/null +++ b/src/audio/AudioSource.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +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 diff --git a/src/audio/FileSource.cpp b/src/audio/FileSource.cpp new file mode 100644 index 0000000..5e299f4 --- /dev/null +++ b/src/audio/FileSource.cpp @@ -0,0 +1,156 @@ +#include "audio/FileSource.h" +#include +#include + +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(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(seconds * sampleRate_ * bytesPerFrame); + rawFile_.clear(); + rawFile_.seekg(pos); + } +} + +double FileSource::duration() const { + if (format_ == InputFormat::WAV && sfInfo_.samplerate > 0) { + return static_cast(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(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(got) : 0; +} + +size_t FileSource::readRawFloat32(float* buffer, size_t frames) { + size_t samples = frames * 2; + rawFile_.read(reinterpret_cast(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 tmp(samples); + rawFile_.read(reinterpret_cast(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 tmp(samples); + rawFile_.read(reinterpret_cast(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 diff --git a/src/audio/FileSource.h b/src/audio/FileSource.h new file mode 100644 index 0000000..1ec415d --- /dev/null +++ b/src/audio/FileSource.h @@ -0,0 +1,58 @@ +#pragma once + +#include "audio/AudioSource.h" +#include "core/Types.h" +#include +#include +#include +#include + +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 diff --git a/src/audio/PortAudioSource.cpp b/src/audio/PortAudioSource.cpp new file mode 100644 index 0000000..71e620a --- /dev/null +++ b/src/audio/PortAudioSource.cpp @@ -0,0 +1,114 @@ +#include "audio/PortAudioSource.h" +#include +#include + +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(sampleRate * channels * 2); // ~2 seconds + ringBuf_ = std::make_unique>(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_, ¶ms, 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(userData); + if (input) { + const auto* in = static_cast(input); + self->ringBuf_->write(in, frameCount * self->channels_); + } + return paContinue; +} + +std::vector PortAudioSource::listInputDevices() { + ensurePaInit(); + std::vector 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 diff --git a/src/audio/PortAudioSource.h b/src/audio/PortAudioSource.h new file mode 100644 index 0000000..d4f75cf --- /dev/null +++ b/src/audio/PortAudioSource.h @@ -0,0 +1,54 @@ +#pragma once + +#include "audio/AudioSource.h" +#include "core/RingBuffer.h" +#include +#include +#include + +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 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> ringBuf_; +}; + +} // namespace baudline diff --git a/src/core/RingBuffer.h b/src/core/RingBuffer.h new file mode 100644 index 0000000..4d15cad --- /dev/null +++ b/src/core/RingBuffer.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include +#include + +namespace baudline { + +// Single-producer single-consumer lock-free ring buffer for audio data. +// Producer: audio callback thread. Consumer: main/render thread. +template +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 buf_; + alignas(64) std::atomic writePos_{0}; + alignas(64) std::atomic readPos_{0}; +}; + +} // namespace baudline diff --git a/src/core/Types.h b/src/core/Types.h new file mode 100644 index 0000000..8b7040b --- /dev/null +++ b/src/core/Types.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include + +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 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 diff --git a/src/dsp/FFTProcessor.cpp b/src/dsp/FFTProcessor.cpp new file mode 100644 index 0000000..cb88f0f --- /dev/null +++ b/src/dsp/FFTProcessor.cpp @@ -0,0 +1,85 @@ +#include "dsp/FFTProcessor.h" +#include +#include + +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& 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& 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 diff --git a/src/dsp/FFTProcessor.h b/src/dsp/FFTProcessor.h new file mode 100644 index 0000000..cf058f2 --- /dev/null +++ b/src/dsp/FFTProcessor.h @@ -0,0 +1,56 @@ +#pragma once + +#include "core/Types.h" +#include +#include +#include + +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& 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& 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 diff --git a/src/dsp/SpectrumAnalyzer.cpp b/src/dsp/SpectrumAnalyzer.cpp new file mode 100644 index 0000000..24192fc --- /dev/null +++ b/src/dsp/SpectrumAnalyzer.cpp @@ -0,0 +1,170 @@ +#include "dsp/SpectrumAnalyzer.h" +#include +#include +#include + +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(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(specSz, -200.0f)); + channelWaterfalls_.assign(nSpec, {}); + + avgAccum_.assign(nSpec, std::vector(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(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(channelSpectra_.size()); + int specSz = fft_.spectrumSize(); + + // Compute per-channel spectra. + std::vector> tempDBs(nSpec); + + if (settings_.isIQ) { + // I/Q: treat the 2 interleaved channels as one complex signal. + std::vector 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 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(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 SpectrumAnalyzer::findPeak(int ch) const { + if (ch < 0 || ch >= static_cast(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(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(bin) / N) * sr; + } else { + return (static_cast(bin) / N) * sr; + } +} + +void SpectrumAnalyzer::clearHistory() { + for (auto& w : channelWaterfalls_) w.clear(); + newSpectrumReady_ = false; +} + +} // namespace baudline diff --git a/src/dsp/SpectrumAnalyzer.h b/src/dsp/SpectrumAnalyzer.h new file mode 100644 index 0000000..afa34c9 --- /dev/null +++ b/src/dsp/SpectrumAnalyzer.h @@ -0,0 +1,83 @@ +#pragma once + +#include "core/Types.h" +#include "dsp/FFTProcessor.h" +#include "dsp/WindowFunctions.h" +#include +#include + +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(channelSpectra_.size()); } + + // Per-channel spectra (dB magnitudes). + const std::vector& channelSpectrum(int ch) const { return channelSpectra_[ch]; } + + // Convenience: first channel spectrum (backward compat / primary). + const std::vector& currentSpectrum() const { return channelSpectra_[0]; } + + // All channel spectra. + const std::vector>& allSpectra() const { return channelSpectra_; } + + // Number of output bins (per channel). + int spectrumSize() const { return fft_.spectrumSize(); } + + // Peak detection on a given channel. + std::pair 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>& waterfallHistory(int ch = 0) const { + return channelWaterfalls_[ch]; + } + +private: + void processBlock(); + + AnalyzerSettings settings_; + FFTProcessor fft_; + std::vector window_; + float windowGain_ = 1.0f; + + // Accumulation buffer (interleaved, length = fftSize * inputChannels) + std::vector accumBuf_; + size_t accumPos_ = 0; + size_t hopSize_ = 0; + + // Per-channel averaging + std::vector> avgAccum_; + int avgCount_ = 0; + + // Per-channel output + std::vector> channelSpectra_; + std::vector>> channelWaterfalls_; + bool newSpectrumReady_ = false; +}; + +} // namespace baudline diff --git a/src/dsp/WindowFunctions.cpp b/src/dsp/WindowFunctions.cpp new file mode 100644 index 0000000..38bd571 --- /dev/null +++ b/src/dsp/WindowFunctions.cpp @@ -0,0 +1,100 @@ +#include "dsp/WindowFunctions.h" +#include +#include + +namespace baudline { + +static constexpr double kPi = 3.14159265358979323846; + +void WindowFunctions::generate(WindowType type, int size, std::vector& 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& window, float* data, int size) { + for (int i = 0; i < size; ++i) + data[i] *= window[i]; +} + +float WindowFunctions::coherentGain(const std::vector& window) { + if (window.empty()) return 1.0f; + double sum = 0.0; + for (float w : window) sum += w; + return static_cast(sum / window.size()); +} + +// ── Window implementations ─────────────────────────────────────────────────── + +void WindowFunctions::rectangular(int N, std::vector& w) { + for (int i = 0; i < N; ++i) + w[i] = 1.0f; +} + +void WindowFunctions::hann(int N, std::vector& w) { + for (int i = 0; i < N; ++i) + w[i] = static_cast(0.5 * (1.0 - std::cos(2.0 * kPi * i / (N - 1)))); +} + +void WindowFunctions::hamming(int N, std::vector& w) { + for (int i = 0; i < N; ++i) + w[i] = static_cast(0.54 - 0.46 * std::cos(2.0 * kPi * i / (N - 1))); +} + +void WindowFunctions::blackman(int N, std::vector& w) { + for (int i = 0; i < N; ++i) { + double x = 2.0 * kPi * i / (N - 1); + w[i] = static_cast(0.42 - 0.5 * std::cos(x) + 0.08 * std::cos(2.0 * x)); + } +} + +void WindowFunctions::blackmanHarris(int N, std::vector& 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(a0 - a1 * std::cos(x) + + a2 * std::cos(2.0 * x) + - a3 * std::cos(3.0 * x)); + } +} + +void WindowFunctions::kaiser(int N, std::vector& 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(besselI0(beta * std::sqrt(1.0 - t * t)) / denom); + } +} + +void WindowFunctions::flatTop(int N, std::vector& 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(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 diff --git a/src/dsp/WindowFunctions.h b/src/dsp/WindowFunctions.h new file mode 100644 index 0000000..8286191 --- /dev/null +++ b/src/dsp/WindowFunctions.h @@ -0,0 +1,32 @@ +#pragma once + +#include "core/Types.h" +#include + +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& out, + float kaiserBeta = 9.0f); + + // Apply window in-place: data[i] *= window[i]. + static void apply(const std::vector& window, float* data, int size); + + // Coherent gain of the window (sum / N), used for amplitude correction. + static float coherentGain(const std::vector& window); + +private: + static void rectangular(int N, std::vector& w); + static void hann(int N, std::vector& w); + static void hamming(int N, std::vector& w); + static void blackman(int N, std::vector& w); + static void blackmanHarris(int N, std::vector& w); + static void kaiser(int N, std::vector& w, float beta); + static void flatTop(int N, std::vector& w); + + static double besselI0(double x); +}; + +} // namespace baudline diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..b708d80 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,15 @@ +#include "ui/Application.h" +#include + +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; +} diff --git a/src/ui/Application.cpp b/src/ui/Application.cpp new file mode 100644 index 0000000..53e478a --- /dev/null +++ b/src/ui/Application.cpp @@ -0,0 +1,718 @@ +#include "ui/Application.h" +#include "audio/FileSource.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +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(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( + 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 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 devNames; + for (auto& d : paDevices_) devNames.push_back(d.name.c_str()); + if (ImGui::Combo("Device", &paDeviceIdx_, devNames.data(), + static_cast(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(WindowType::Count))) { + settings_.window = static_cast(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(ColorMapType::Count))) { + colorMap_.setType(static_cast(colorMapIdx_)); + waterfall_.setColorMap(colorMap_); + } + } + + // Frequency scale + { + int fs = static_cast(freqScale_); + const char* fsNames[] = {"Linear", "Logarithmic"}; + if (ImGui::Combo("Freq Scale", &fs, fsNames, 2)) + freqScale_ = static_cast(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 styles(nCh); + for (int ch = 0; ch < nCh; ++ch) { + const auto& c = channelColors_[ch % kMaxChannels]; + uint8_t r = static_cast(c.x * 255); + uint8_t g = static_cast(c.y * 255); + uint8_t b = static_cast(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(availW); + int newH = static_cast(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(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(waterfall_.textureID()); + + int h = waterfall_.height(); + int cur = (waterfall_.currentRow() + 1) % h; + float splitFrac = static_cast(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(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(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(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((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(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(paDevices_.size())) + reqCh = std::min(paDevices_[paDeviceIdx_].maxInputChannels, kMaxChannels); + if (reqCh < 1) reqCh = 1; + auto src = std::make_unique(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(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(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(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 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 diff --git a/src/ui/Application.h b/src/ui/Application.h new file mode 100644 index 0000000..ced9b4b --- /dev/null +++ b/src/ui/Application.h @@ -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 +#include +#include +#include + +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_; + std::vector audioBuf_; // temp read buffer + + // DSP + SpectrumAnalyzer analyzer_; + AnalyzerSettings settings_; + + // UI state + ColorMap colorMap_; + WaterfallDisplay waterfall_; + SpectrumDisplay specDisplay_; + Cursors cursors_; + + // Display settings + float minDB_ = -120.0f; + float maxDB_ = 0.0f; + FreqScale freqScale_ = FreqScale::Linear; + bool paused_ = false; + int waterfallW_ = 0; + int waterfallH_ = 0; + + // FFT size options + static constexpr int kFFTSizes[] = {256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}; + static constexpr int kNumFFTSizes = 9; + int fftSizeIdx_ = 4; // default 4096 + + // Overlap (continuous 0–95%) + float overlapPct_ = 50.0f; + + // Window + int windowIdx_ = static_cast(WindowType::BlackmanHarris); + + // Color map + int colorMapIdx_ = static_cast(ColorMapType::Magma); + + // File playback + std::string filePath_; + int fileFormatIdx_ = 0; + float fileSampleRate_ = 48000.0f; + bool fileLoop_ = true; + + // Device selection + std::vector 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 diff --git a/src/ui/ColorMap.cpp b/src/ui/ColorMap.cpp new file mode 100644 index 0000000..0091268 --- /dev/null +++ b/src/ui/ColorMap.cpp @@ -0,0 +1,108 @@ +#include "ui/ColorMap.h" +#include +#include + +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(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(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(i), + static_cast(i), + static_cast(i)}; + break; + default: + lut_[i] = {static_cast(i), + static_cast(i), + static_cast(i)}; + break; + } + } +} + +} // namespace baudline diff --git a/src/ui/ColorMap.h b/src/ui/ColorMap.h new file mode 100644 index 0000000..a1bea28 --- /dev/null +++ b/src/ui/ColorMap.h @@ -0,0 +1,31 @@ +#pragma once + +#include "core/Types.h" +#include + +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& lut() const { return lut_; } + +private: + void buildLUT(); + + ColorMapType type_; + std::vector lut_; // 256 entries +}; + +} // namespace baudline diff --git a/src/ui/Cursors.cpp b/src/ui/Cursors.cpp new file mode 100644 index 0000000..d7fe819 --- /dev/null +++ b/src/ui/Cursors.cpp @@ -0,0 +1,176 @@ +#include "ui/Cursors.h" +#include +#include +#include + +namespace baudline { + +static double binToFreqHelper(int bin, double sampleRate, bool isIQ, int fftSize) { + if (isIQ) { + return -sampleRate / 2.0 + (static_cast(bin) / fftSize) * sampleRate; + } else { + return (static_cast(bin) / fftSize) * sampleRate; + } +} + +void Cursors::update(const std::vector& spectrumDB, + double sampleRate, bool isIQ, int fftSize) { + // Update dB values at cursor bin positions + if (cursorA.active && cursorA.bin >= 0 && + cursorA.bin < static_cast(spectrumDB.size())) { + cursorA.dB = spectrumDB[cursorA.bin]; + } + if (cursorB.active && cursorB.bin >= 0 && + cursorB.bin < static_cast(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& spectrumDB, + double sampleRate, bool isIQ, int fftSize) { + if (spectrumDB.empty()) return; + auto it = std::max_element(spectrumDB.begin(), spectrumDB.end()); + int bin = static_cast(std::distance(spectrumDB.begin(), it)); + double freq = binToFreqHelper(bin, sampleRate, isIQ, fftSize); + setCursorA(freq, *it, bin); +} + +int Cursors::findLocalPeak(const std::vector& spectrumDB, + int centerBin, int window) const { + int bins = static_cast(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 diff --git a/src/ui/Cursors.h b/src/ui/Cursors.h new file mode 100644 index 0000000..db4a21f --- /dev/null +++ b/src/ui/Cursors.h @@ -0,0 +1,51 @@ +#pragma once + +#include "core/Types.h" +#include "ui/SpectrumDisplay.h" +#include + +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& 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& spectrumDB, + double sampleRate, bool isIQ, int fftSize); + + // Find peak near a given bin (within a window). + int findLocalPeak(const std::vector& spectrumDB, + int centerBin, int window = 20) const; + + CursorInfo cursorA; + CursorInfo cursorB; + bool showDelta = true; + + // Hover cursor (follows mouse, always active) + CursorInfo hover; +}; + +} // namespace baudline diff --git a/src/ui/SpectrumDisplay.cpp b/src/ui/SpectrumDisplay.cpp new file mode 100644 index 0000000..b2d678f --- /dev/null +++ b/src/ui/SpectrumDisplay.cpp @@ -0,0 +1,212 @@ +#include "ui/SpectrumDisplay.h" +#include +#include + +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((logF - logMin) / (logMax - logMin)); +} + +// Build a decimated polyline for one spectrum. +static void buildPolyline(const std::vector& spectrumDB, + float minDB, float maxDB, + double freqMin, double freqMax, + bool isIQ, FreqScale freqScale, + float posX, float posY, float sizeX, float sizeY, + std::vector& outPoints) { + int bins = static_cast(spectrumDB.size()); + int displayPts = std::min(bins, static_cast(sizeX)); + if (displayPts < 2) displayPts = 2; + + outPoints.resize(displayPts); + for (int idx = 0; idx < displayPts; ++idx) { + float frac = static_cast(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(idx - 1) / (displayPts - 1) * (bins - 1) + : binF; + float binNext = (idx < displayPts - 1) + ? static_cast(idx + 1) / (displayPts - 1) * (bins - 1) + : binF; + int b0 = static_cast((binPrev + binF) * 0.5f); + int b1 = static_cast((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>& spectra, + const std::vector& 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(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 points; + int nCh = static_cast(spectra.size()); + for (int ch = 0; ch < nCh; ++ch) { + if (spectra[ch].empty()) continue; + const ChannelStyle& st = (ch < static_cast(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(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& spectrumDB, + float minDB, float maxDB, + double sampleRate, bool isIQ, + FreqScale freqScale, + float posX, float posY, + float sizeX, float sizeY) const { + std::vector> spectra = {spectrumDB}; + std::vector 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((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 diff --git a/src/ui/SpectrumDisplay.h b/src/ui/SpectrumDisplay.h new file mode 100644 index 0000000..703b454 --- /dev/null +++ b/src/ui/SpectrumDisplay.h @@ -0,0 +1,43 @@ +#pragma once + +#include "core/Types.h" +#include +#include + +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>& spectra, + const std::vector& 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& 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 diff --git a/src/ui/WaterfallDisplay.cpp b/src/ui/WaterfallDisplay.cpp new file mode 100644 index 0000000..b62fb40 --- /dev/null +++ b/src/ui/WaterfallDisplay.cpp @@ -0,0 +1,128 @@ +#include "ui/WaterfallDisplay.h" +#include +#include +#include + +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& spec, float binF) { + int bins = static_cast(spec.size()); + int b0 = static_cast(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& spectrumDB, + float minDB, float maxDB) { + if (width_ == 0 || height_ == 0) return; + + int bins = static_cast(spectrumDB.size()); + int row = currentRow_; + int rowOffset = row * width_ * 3; + + for (int x = 0; x < width_; ++x) { + float frac = static_cast(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>& channelSpectra, + const std::vector& channels, + float minDB, float maxDB) { + if (width_ == 0 || height_ == 0) return; + + int nCh = static_cast(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(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(channels.size()) || !channels[ch].enabled) + continue; + if (channelSpectra[ch].empty()) continue; + + int bins = static_cast(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(std::min(accR, 1.0f) * 255.0f); + pixelBuf_[rowOffset + x * 3 + 1] = + static_cast(std::min(accG, 1.0f) * 255.0f); + pixelBuf_[rowOffset + x * 3 + 2] = + static_cast(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 diff --git a/src/ui/WaterfallDisplay.h b/src/ui/WaterfallDisplay.h new file mode 100644 index 0000000..b2b8c3e --- /dev/null +++ b/src/ui/WaterfallDisplay.h @@ -0,0 +1,64 @@ +#pragma once + +#include "core/Types.h" +#include "ui/ColorMap.h" +#include +#include +#include + +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& 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>& channelSpectra, + const std::vector& 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& spec, float binF); + + GLuint texture_ = 0; + int width_ = 0; + int height_ = 0; + int currentRow_ = 0; + + ColorMap colorMap_; + std::vector pixelBuf_; +}; + +} // namespace baudline