#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()) { computeMathChannels(); int nSpec = analyzer_.numSpectra(); if (waterfallMultiCh_ && nSpec > 1) { // Multi-channel overlay waterfall: physical + math channels. wfSpectraScratch_.clear(); wfChInfoScratch_.clear(); for (int ch = 0; ch < nSpec; ++ch) { const auto& c = channelColors_[ch % kMaxChannels]; wfSpectraScratch_.push_back(analyzer_.channelSpectrum(ch)); wfChInfoScratch_.push_back({c.x, c.y, c.z, channelEnabled_[ch % kMaxChannels]}); } for (size_t mi = 0; mi < mathChannels_.size(); ++mi) { if (mathChannels_[mi].enabled && mathChannels_[mi].waterfall && mi < mathSpectra_.size()) { const auto& c = mathChannels_[mi].color; wfSpectraScratch_.push_back(mathSpectra_[mi]); wfChInfoScratch_.push_back({c.x, c.y, c.z, true}); } } waterfall_.pushLineMulti(wfSpectraScratch_, wfChInfoScratch_, 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::Separator(); if (ImGui::MenuItem("VSync", nullptr, &vsync_)) { SDL_GL_SetSwapInterval(vsync_ ? 1 : 0); } ImGui::EndMenu(); } if (ImGui::BeginMenu("Debug")) { ImGui::MenuItem("Metrics/Debugger", nullptr, &showMetricsWindow_); ImGui::MenuItem("Debug Log", nullptr, &showDebugLog_); ImGui::MenuItem("Stack Tool", nullptr, &showStackTool_); ImGui::MenuItem("Demo Window", nullptr, &showDemoWindow_); ImGui::Separator(); ImGui::Text("%.1f FPS (%.3f ms)", ImGui::GetIO().Framerate, 1000.0f / ImGui::GetIO().Framerate); 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 with draggable splitter ImGui::BeginChild("Display", {contentW, contentH}, false); { constexpr float kSplitterH = 6.0f; float specH = contentH * spectrumFrac_; float waterfH = contentH - specH - kSplitterH; renderSpectrumPanel(); // ── Draggable splitter bar ── ImVec2 splPos = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton("##splitter", {contentW, kSplitterH}); bool hovered = ImGui::IsItemHovered(); bool active = ImGui::IsItemActive(); if (hovered || active) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); if (active) { float dy = ImGui::GetIO().MouseDelta.y; spectrumFrac_ += dy / contentH; spectrumFrac_ = std::clamp(spectrumFrac_, 0.1f, 0.9f); } // Draw splitter line ImU32 splCol = (hovered || active) ? IM_COL32(100, 150, 255, 220) : IM_COL32(80, 80, 100, 150); ImDrawList* dl = ImGui::GetWindowDrawList(); float cy = splPos.y + kSplitterH * 0.5f; dl->AddLine({splPos.x, cy}, {splPos.x + contentW, cy}, splCol, 2.0f); renderWaterfallPanel(); // ── Cross-panel hover line & frequency label ── if (cursors_.hover.active && specSizeX_ > 0 && wfSizeX_ > 0) { ImDrawList* dlp = ImGui::GetWindowDrawList(); float hx = specDisplay_.freqToScreenX(cursors_.hover.freq, specPosX_, specSizeX_, settings_.sampleRate, settings_.isIQ, freqScale_, viewLo_, viewHi_); ImU32 hoverCol = IM_COL32(200, 200, 200, 80); // Line spanning spectrum + splitter + waterfall dlp->AddLine({hx, specPosY_}, {hx, wfPosY_ + wfSizeY_}, hoverCol, 1.0f); // Frequency label at top of the line char freqLabel[48]; double hf = cursors_.hover.freq; if (std::abs(hf) >= 1e6) std::snprintf(freqLabel, sizeof(freqLabel), "%.6f MHz", hf / 1e6); else if (std::abs(hf) >= 1e3) std::snprintf(freqLabel, sizeof(freqLabel), "%.3f kHz", hf / 1e3); else std::snprintf(freqLabel, sizeof(freqLabel), "%.1f Hz", hf); ImVec2 tSz = ImGui::CalcTextSize(freqLabel); float lx = std::min(hx + 4, specPosX_ + specSizeX_ - tSz.x - 4); float ly = specPosY_ + 2; dlp->AddRectFilled({lx - 2, ly - 1}, {lx + tSz.x + 2, ly + tSz.y + 1}, IM_COL32(0, 0, 0, 180)); dlp->AddText({lx, ly}, IM_COL32(220, 220, 240, 240), freqLabel); } } ImGui::EndChild(); ImGui::End(); // ImGui debug windows if (showDemoWindow_) ImGui::ShowDemoWindow(&showDemoWindow_); if (showMetricsWindow_) ImGui::ShowMetricsWindow(&showMetricsWindow_); if (showDebugLog_) ImGui::ShowDebugLogWindow(&showDebugLog_); if (showStackTool_) ImGui::ShowIDStackToolWindow(&showStackTool_); // 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 — inverted x⁴ curve: sensitive at the high end (90%+). { int hopSamples = static_cast(settings_.fftSize * (1.0f - settings_.overlap)); if (hopSamples < 1) hopSamples = 1; int overlapSamples = settings_.fftSize - hopSamples; float sliderVal = 1.0f - std::pow(1.0f - overlapPct_ / 99.0f, 0.25f); if (ImGui::SliderFloat("Overlap", &sliderVal, 0.0f, 1.0f, "")) { float inv = 1.0f - sliderVal; float inv2 = inv * inv; overlapPct_ = 99.0f * (1.0f - inv2 * inv2); settings_.overlap = overlapPct_ / 100.0f; updateAnalyzerSettings(); } // Draw overlay text centered on the slider frame (not the label). char overlayText[64]; std::snprintf(overlayText, sizeof(overlayText), "%.1f%% (%d samples)", overlapPct_, overlapSamples); ImVec2 textSize = ImGui::CalcTextSize(overlayText); // The slider frame width = total widget width minus label. // ImGui::CalcItemWidth() gives the frame width. ImVec2 sliderMin = ImGui::GetItemRectMin(); float frameW = ImGui::CalcItemWidth(); float frameH = ImGui::GetItemRectMax().y - sliderMin.y; float tx = sliderMin.x + (frameW - textSize.x) * 0.5f; float ty = sliderMin.y + (frameH - textSize.y) * 0.5f; ImGui::GetForegroundDrawList()->AddText({tx, ty}, IM_COL32(255, 255, 255, 220), overlayText); } // Window function { const char* winNames[] = {"Rectangular", "Hann", "Hamming", "Blackman", "Blackman-Harris", "Kaiser", "Flat Top"}; if (ImGui::Combo("Window", &windowIdx_, winNames, static_cast(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(); } } 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); } // Zoom info & reset if (viewLo_ > 0.0f || viewHi_ < 1.0f) { float zoomPct = 1.0f / (viewHi_ - viewLo_); ImGui::Text("Zoom: %.1fx", zoomPct); ImGui::SameLine(); if (ImGui::SmallButton("Reset")) { viewLo_ = 0.0f; viewHi_ = 1.0f; } } ImGui::TextDisabled("Scroll: freq zoom | MMB drag: pan"); ImGui::TextDisabled("Ctrl+Scroll: dB zoom | MMB dbl: reset"); // dB range ImGui::DragFloatRange2("dB Range", &minDB_, &maxDB_, 1.0f, -200.0f, 20.0f, "Min: %.0f", "Max: %.0f"); // Peak hold ImGui::Checkbox("Peak Hold", &specDisplay_.peakHoldEnable); if (specDisplay_.peakHoldEnable) { ImGui::SameLine(); ImGui::SetNextItemWidth(80); ImGui::SliderFloat("Decay", &specDisplay_.peakHoldDecay, 0.0f, 120.0f, "%.0f dB/s"); ImGui::SameLine(); if (ImGui::SmallButton("Clear##peakhold")) specDisplay_.clearPeakHold(); } // Channel colors (only shown for multi-channel) int nCh = analyzer_.numSpectra(); if (nCh > 1) { ImGui::Separator(); ImGui::Text("Channels (%d)", nCh); static const char* defaultNames[] = { "Left", "Right", "Ch 3", "Ch 4", "Ch 5", "Ch 6", "Ch 7", "Ch 8" }; for (int ch = 0; ch < nCh && ch < kMaxChannels; ++ch) { ImGui::PushID(ch); ImGui::Checkbox("##en", &channelEnabled_[ch]); ImGui::SameLine(); ImGui::ColorEdit3(defaultNames[ch], &channelColors_[ch].x, ImGuiColorEditFlags_NoInputs); ImGui::PopID(); } // Waterfall mode ImGui::Checkbox("Multi-Ch Waterfall", &waterfallMultiCh_); if (!waterfallMultiCh_) { if (ImGui::SliderInt("Waterfall Ch", &waterfallChannel_, 0, nCh - 1)) waterfallChannel_ = std::clamp(waterfallChannel_, 0, nCh - 1); } } // Math channels section (always shown). ImGui::Separator(); renderMathPanel(); ImGui::Separator(); // Playback controls if (ImGui::Button(paused_ ? "Resume [Space]" : "Pause [Space]")) 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; // Use the parent's full content height (availY includes spectrum + splitter + waterfall) // to compute the spectrum height from the split fraction. constexpr float kSplitterH = 6.0f; float parentH = ImGui::GetContentRegionAvail().y; float specH = (parentH - kSplitterH) * spectrumFrac_; ImVec2 pos = ImGui::GetCursorScreenPos(); specPosX_ = pos.x; specPosY_ = pos.y; specSizeX_ = availW; specSizeY_ = specH; // Build per-channel styles and combine physical + math spectra. int nPhys = analyzer_.numSpectra(); int nMath = static_cast(mathSpectra_.size()); allSpectraScratch_.clear(); stylesScratch_.clear(); // Physical channels. for (int ch = 0; ch < nPhys; ++ch) { allSpectraScratch_.push_back(analyzer_.channelSpectrum(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); stylesScratch_.push_back({IM_COL32(r, g, b, 220), IM_COL32(r, g, b, 35)}); } // Math channels. for (int mi = 0; mi < nMath; ++mi) { if (mi < static_cast(mathChannels_.size()) && mathChannels_[mi].enabled) { allSpectraScratch_.push_back(mathSpectra_[mi]); const auto& c = mathChannels_[mi].color; uint8_t r = static_cast(c.x * 255); uint8_t g = static_cast(c.y * 255); uint8_t b = static_cast(c.z * 255); stylesScratch_.push_back({IM_COL32(r, g, b, 220), IM_COL32(r, g, b, 35)}); } } specDisplay_.updatePeakHold(allSpectraScratch_); specDisplay_.draw(allSpectraScratch_, stylesScratch_, minDB_, maxDB_, settings_.sampleRate, settings_.isIQ, freqScale_, specPosX_, specPosY_, specSizeX_, specSizeY_, viewLo_, viewHi_); cursors_.draw(specDisplay_, specPosX_, specPosY_, specSizeX_, specSizeY_, settings_.sampleRate, settings_.isIQ, freqScale_, minDB_, maxDB_, viewLo_, viewHi_); handleSpectrumInput(specPosX_, specPosY_, specSizeX_, specSizeY_); ImGui::Dummy({availW, specH}); } void Application::renderWaterfallPanel() { float availW = ImGui::GetContentRegionAvail().x; float availH = ImGui::GetContentRegionAvail().y; // History depth must be >= panel height for 1:1 pixel mapping. // Only recreate when bin count or needed height actually changes. int neededH = std::max(1024, static_cast(availH) + 1); int binCount = std::max(1, analyzer_.spectrumSize()); if (binCount != waterfall_.width() || waterfall_.height() < neededH) { waterfall_.resize(binCount, neededH); waterfall_.setColorMap(colorMap_); } if (waterfall_.textureID()) { ImVec2 pos = ImGui::GetCursorScreenPos(); ImDrawList* dl = ImGui::GetWindowDrawList(); auto texID = static_cast(waterfall_.textureID()); int h = waterfall_.height(); // The newest row was just written at currentRow()+1 (mod h) — but // advanceRow already decremented, so currentRow() IS the newest. // The row *after* currentRow() (i.e. currentRow()+1) is the oldest // visible row. We only want the most recent screenRows rows so // that every texture row maps to exactly one screen pixel. int screenRows = std::min(static_cast(availH), h); // Newest row index in the circular buffer. int newestRow = (waterfall_.currentRow() + 1) % h; // Render 1:1 (one texture row = one screen pixel), top-aligned, // newest line at top (right below the spectrogram), scrolling down. // // advanceRow() decrements currentRow_, so rows are written at // decreasing indices. Going from newest to oldest = increasing // index (mod h). Normal V order (no flip needed). float rowToV = 1.0f / h; float screenY = pos.y; bool logMode = (freqScale_ == FreqScale::Logarithmic && !settings_.isIQ); auto drawSpan = [&](int rowStart, int rowCount, float yStart, float spanH) { float v0 = rowStart * rowToV; float v1 = (rowStart + rowCount) * rowToV; if (!logMode) { dl->AddImage(texID, {pos.x, yStart}, {pos.x + availW, yStart + spanH}, {viewLo_, v0}, {viewHi_, v1}); } else { constexpr float kMinBinFrac = 0.001f; float logMin2 = std::log10(kMinBinFrac); float logMax2 = 0.0f; int numStrips = std::min(512, static_cast(availW)); for (int s = 0; s < numStrips; ++s) { float sL = static_cast(s) / numStrips; float sR = static_cast(s + 1) / numStrips; float vfL = viewLo_ + sL * (viewHi_ - viewLo_); float vfR = viewLo_ + sR * (viewHi_ - viewLo_); float uL = std::pow(10.0f, logMin2 + vfL * (logMax2 - logMin2)); float uR = std::pow(10.0f, logMin2 + vfR * (logMax2 - logMin2)); dl->AddImage(texID, {pos.x + sL * availW, yStart}, {pos.x + sR * availW, yStart + spanH}, {uL, v0}, {uR, v1}); } } }; // From newestRow, walk forward (increasing index mod h) for // screenRows steps to cover newest→oldest. // Use availH for the screen extent so there's no fractional pixel gap. float pxPerRow = availH / static_cast(screenRows); if (newestRow + screenRows <= h) { drawSpan(newestRow, screenRows, screenY, availH); } else { int firstCount = h - newestRow; float firstH = firstCount * pxPerRow; drawSpan(newestRow, firstCount, screenY, firstH); int secondCount = screenRows - firstCount; if (secondCount > 0) drawSpan(0, secondCount, screenY + firstH, availH - firstH); } // ── Frequency axis labels ── ImU32 textCol = IM_COL32(180, 180, 200, 200); double freqFullMin = settings_.isIQ ? -settings_.sampleRate / 2.0 : 0.0; double freqFullMax = settings_.isIQ ? settings_.sampleRate / 2.0 : settings_.sampleRate / 2.0; // Map a view fraction to frequency. In log mode, viewLo_/viewHi_ // are in screen-fraction space; convert via the log mapping. auto viewFracToFreq = [&](float vf) -> double { if (logMode) { constexpr float kMinBinFrac = 0.001f; float logMin2 = std::log10(kMinBinFrac); float logMax2 = 0.0f; float binFrac = std::pow(10.0f, logMin2 + vf * (logMax2 - logMin2)); return freqFullMin + binFrac * (freqFullMax - freqFullMin); } return freqFullMin + vf * (freqFullMax - freqFullMin); }; int numLabels = 8; for (int i = 0; i <= numLabels; ++i) { float frac = static_cast(i) / numLabels; float vf = viewLo_ + frac * (viewHi_ - viewLo_); double freq = viewFracToFreq(vf); float x = pos.x + frac * availW; char label[32]; if (std::abs(freq) >= 1e6) std::snprintf(label, sizeof(label), "%.2fM", freq / 1e6); else if (std::abs(freq) >= 1e3) std::snprintf(label, sizeof(label), "%.1fk", freq / 1e3); else std::snprintf(label, sizeof(label), "%.0f", freq); dl->AddText({x + 2, pos.y + availH - 14}, textCol, label); } // Store waterfall geometry for cross-panel cursor drawing. wfPosX_ = pos.x; wfPosY_ = pos.y; wfSizeX_ = availW; wfSizeY_ = availH; // ── Mouse interaction: zoom, pan & hover on waterfall ── ImGuiIO& io = ImGui::GetIO(); float mx = io.MousePos.x; float my = io.MousePos.y; bool inWaterfall = mx >= pos.x && mx <= pos.x + availW && my >= pos.y && my <= pos.y + availH; // Hover cursor from waterfall if (inWaterfall) { double freq = specDisplay_.screenXToFreq(mx, pos.x, availW, settings_.sampleRate, settings_.isIQ, freqScale_, viewLo_, viewHi_); int bins = analyzer_.spectrumSize(); double fMin = settings_.isIQ ? -settings_.sampleRate / 2.0 : 0.0; double fMax = settings_.isIQ ? settings_.sampleRate / 2.0 : settings_.sampleRate / 2.0; int bin = static_cast((freq - fMin) / (fMax - fMin) * (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()) { cursors_.hover = {true, freq, spec[bin], bin}; } } if (inWaterfall) { // Scroll wheel: zoom centered on cursor if (io.MouseWheel != 0) { float cursorFrac = (mx - pos.x) / availW; // 0..1 on screen float viewFrac = viewLo_ + cursorFrac * (viewHi_ - viewLo_); float zoomFactor = (io.MouseWheel > 0) ? 0.85f : 1.0f / 0.85f; float newSpan = (viewHi_ - viewLo_) * zoomFactor; newSpan = std::clamp(newSpan, 0.001f, 1.0f); float newLo = viewFrac - cursorFrac * newSpan; float newHi = newLo + newSpan; // Clamp to [0, 1] if (newLo < 0.0f) { newHi -= newLo; newLo = 0.0f; } if (newHi > 1.0f) { newLo -= (newHi - 1.0f); newHi = 1.0f; } viewLo_ = std::clamp(newLo, 0.0f, 1.0f); viewHi_ = std::clamp(newHi, 0.0f, 1.0f); } // Middle-click + drag: pan if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 1.0f)) { float dx = io.MouseDelta.x; float panFrac = -dx / availW * (viewHi_ - viewLo_); float newLo = viewLo_ + panFrac; float newHi = viewHi_ + panFrac; float span = viewHi_ - viewLo_; if (newLo < 0.0f) { newLo = 0.0f; newHi = span; } if (newHi > 1.0f) { newHi = 1.0f; newLo = 1.0f - span; } viewLo_ = newLo; viewHi_ = newHi; } // Double-click: reset zoom if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Middle)) { viewLo_ = 0.0f; viewHi_ = 1.0f; } } } 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_, viewLo_, viewHi_); 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)) { 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)) { int peakBin = cursors_.findLocalPeak(spec, bin, 10); double peakFreq = analyzer_.binToFreq(peakBin); cursors_.setCursorB(peakFreq, spec[peakBin], peakBin); } { // Ctrl+Scroll or Shift+Scroll: zoom dB range if (io.MouseWheel != 0 && (io.KeyCtrl || io.KeyShift)) { 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; } } // Scroll (no modifier): zoom frequency axis centered on cursor else if (io.MouseWheel != 0) { float cursorFrac = (mx - posX) / sizeX; float viewFrac = viewLo_ + cursorFrac * (viewHi_ - viewLo_); float zoomFactor = (io.MouseWheel > 0) ? 0.85f : 1.0f / 0.85f; float newSpan = (viewHi_ - viewLo_) * zoomFactor; newSpan = std::clamp(newSpan, 0.001f, 1.0f); float newLo = viewFrac - cursorFrac * newSpan; float newHi = newLo + newSpan; if (newLo < 0.0f) { newHi -= newLo; newLo = 0.0f; } if (newHi > 1.0f) { newLo -= (newHi - 1.0f); newHi = 1.0f; } viewLo_ = std::clamp(newLo, 0.0f, 1.0f); viewHi_ = std::clamp(newHi, 0.0f, 1.0f); } // Middle-click + drag: pan if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 1.0f)) { float dx = io.MouseDelta.x; float panFrac = -dx / sizeX * (viewHi_ - viewLo_); float newLo = viewLo_ + panFrac; float newHi = viewHi_ + panFrac; float span = viewHi_ - viewLo_; if (newLo < 0.0f) { newLo = 0.0f; newHi = span; } if (newHi > 1.0f) { newHi = 1.0f; newLo = 1.0f - span; } viewLo_ = newLo; viewHi_ = newHi; } // Double middle-click: reset zoom if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Middle)) { viewLo_ = 0.0f; viewHi_ = 1.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. int reinitH = std::max(1024, waterfall_.height()); int binCount2 = std::max(1, analyzer_.spectrumSize()); waterfall_.init(binCount2, reinitH); } } // ── Math channels ──────────────────────────────────────────────────────────── void Application::computeMathChannels() { int nPhys = analyzer_.numSpectra(); int specSz = analyzer_.spectrumSize(); mathSpectra_.resize(mathChannels_.size()); for (size_t mi = 0; mi < mathChannels_.size(); ++mi) { const auto& mc = mathChannels_[mi]; auto& out = mathSpectra_[mi]; out.resize(specSz); if (!mc.enabled) { std::fill(out.begin(), out.end(), -200.0f); continue; } int sx = std::clamp(mc.sourceX, 0, nPhys - 1); int sy = std::clamp(mc.sourceY, 0, nPhys - 1); const auto& xDB = analyzer_.channelSpectrum(sx); const auto& yDB = analyzer_.channelSpectrum(sy); const auto& xC = analyzer_.channelComplex(sx); const auto& yC = analyzer_.channelComplex(sy); for (int i = 0; i < specSz; ++i) { float val = -200.0f; switch (mc.op) { // ── Unary ── case MathOp::Negate: val = -xDB[i]; break; case MathOp::Absolute: val = std::abs(xDB[i]); break; case MathOp::Square: val = 2.0f * xDB[i]; break; case MathOp::Cube: val = 3.0f * xDB[i]; break; case MathOp::Sqrt: val = 0.5f * xDB[i]; break; case MathOp::Log: { // log10 of linear magnitude, back to dB-like scale. float lin = std::pow(10.0f, xDB[i] / 10.0f); float l = std::log10(lin + 1e-30f); val = 10.0f * l; // keep in dB-like range break; } // ── Binary ── case MathOp::Add: { float lx = std::pow(10.0f, xDB[i] / 10.0f); float ly = std::pow(10.0f, yDB[i] / 10.0f); float s = lx + ly; val = (s > 1e-20f) ? 10.0f * std::log10(s) : -200.0f; break; } case MathOp::Subtract: { float lx = std::pow(10.0f, xDB[i] / 10.0f); float ly = std::pow(10.0f, yDB[i] / 10.0f); float d = std::abs(lx - ly); val = (d > 1e-20f) ? 10.0f * std::log10(d) : -200.0f; break; } case MathOp::Multiply: val = xDB[i] + yDB[i]; break; case MathOp::Phase: { if (i < static_cast(xC.size()) && i < static_cast(yC.size())) { auto cross = xC[i] * std::conj(yC[i]); float deg = std::atan2(cross.imag(), cross.real()) * (180.0f / 3.14159265f); // Map [-180, 180] degrees into the dB display range // so it's visible on the plot. val = deg; } break; } case MathOp::CrossCorr: { if (i < static_cast(xC.size()) && i < static_cast(yC.size())) { auto cross = xC[i] * std::conj(yC[i]); float mag2 = std::norm(cross); val = (mag2 > 1e-20f) ? 10.0f * std::log10(mag2) : -200.0f; } break; } default: break; } out[i] = val; } } } void Application::renderMathPanel() { ImGui::Text("Channel Math"); ImGui::Separator(); int nPhys = analyzer_.numSpectra(); // Build source channel name list. static const char* chNames[] = { "Ch 0 (L)", "Ch 1 (R)", "Ch 2", "Ch 3", "Ch 4", "Ch 5", "Ch 6", "Ch 7" }; // List existing math channels. int toRemove = -1; for (int mi = 0; mi < static_cast(mathChannels_.size()); ++mi) { auto& mc = mathChannels_[mi]; ImGui::PushID(1000 + mi); ImGui::Checkbox("##en", &mc.enabled); ImGui::SameLine(); ImGui::ColorEdit3("##col", &mc.color.x, ImGuiColorEditFlags_NoInputs); ImGui::SameLine(); // Operation combo. if (ImGui::BeginCombo("##op", mathOpName(mc.op), ImGuiComboFlags_NoPreview)) { for (int o = 0; o < static_cast(MathOp::Count); ++o) { auto op = static_cast(o); if (ImGui::Selectable(mathOpName(op), mc.op == op)) mc.op = op; } ImGui::EndCombo(); } ImGui::SameLine(); ImGui::Text("%s", mathOpName(mc.op)); // Source X. ImGui::SetNextItemWidth(80); ImGui::Combo("X", &mc.sourceX, chNames, std::min(nPhys, kMaxChannels)); // Source Y (only for binary ops). if (mathOpIsBinary(mc.op)) { ImGui::SameLine(); ImGui::SetNextItemWidth(80); ImGui::Combo("Y", &mc.sourceY, chNames, std::min(nPhys, kMaxChannels)); } ImGui::SameLine(); ImGui::Checkbox("WF", &mc.waterfall); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Show on waterfall"); ImGui::SameLine(); if (ImGui::SmallButton("X##del")) toRemove = mi; ImGui::PopID(); } if (toRemove >= 0) mathChannels_.erase(mathChannels_.begin() + toRemove); if (ImGui::Button("+ Add Math Channel")) { MathChannel mc; mc.op = MathOp::Subtract; mc.sourceX = 0; mc.sourceY = std::min(1, nPhys - 1); mc.color = {1.0f, 1.0f, 0.5f, 1.0f}; mathChannels_.push_back(mc); } } } // namespace baudline