From c45451476d1ca6d7e8f26d101f42c6803972dc4b Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:02:26 -0400 Subject: [PATCH] settings, toggles, and more --- README.md | 16 +- native/src/oscilloscope.cpp | 7 + native/src/oscilloscope.h | 5 + tui/CMakeLists.txt | 6 + tui/src/analysis_pipeline.cpp | 115 +++++- tui/src/analysis_pipeline.h | 33 ++ tui/src/cli.cpp | 4 +- tui/src/dashboard_layout.cpp | 61 ++- tui/src/dashboard_layout.h | 7 + tui/src/scope_plot_model.cpp | 230 +++++++++++ tui/src/scope_plot_model.h | 50 +++ tui/src/spectrum_peak_model.cpp | 178 +++++++++ tui/src/spectrum_peak_model.h | 29 ++ tui/src/tui_runtime.cpp | 672 ++++++++++++++++++++++++++++++-- tui/src/tui_settings.cpp | 409 +++++++++++++++++++ tui/src/tui_settings.h | 80 ++++ tui/test/tui_tests.cpp | 308 ++++++++++++++- 17 files changed, 2144 insertions(+), 66 deletions(-) create mode 100644 tui/src/scope_plot_model.cpp create mode 100644 tui/src/scope_plot_model.h create mode 100644 tui/src/spectrum_peak_model.cpp create mode 100644 tui/src/spectrum_peak_model.h create mode 100644 tui/src/tui_settings.cpp create mode 100644 tui/src/tui_settings.h diff --git a/README.md b/README.md index 236a4d6..591778f 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,7 @@ Capture-to-display latency measures under 8ms. When tested at 120fps, measured l Installable Prism packages also provide `prism-tui`, a native terminal frontend for the shared C++ capture and analysis engine. It shows a responsive spectrum, -stereo VU meters, and momentary, short-term, and integrated LUFS readings. The -dashboard uses a 4096-point FFT by default and automatically switches between -stacked and column layouts as the terminal is resized. +stereo VU meters, and momentary, short-term, and integrated LUFS readings. ```bash prism-tui # Capture the default system output @@ -63,13 +61,11 @@ prism-tui --help prism-tui --version ``` -Press Tab or Shift-Tab to focus a panel, Enter to expand or restore it, and `l` -to cycle automatic, stacked, and column layouts. Number keys `1` and `2` focus -Spectrum and Levels. Press `r` to reset the analyzers and integrated loudness, -or `q`, Escape, or Ctrl-C to quit. Interactive mode requires a terminal of at -least 44 by 12 cells. Quote a device ID if it contains spaces. Successful help, -version, listing, and interactive exits return `0`; usage errors return `2`; -capture and runtime failures return `1`. +Press `r` to reset the analyzers and integrated loudness, or `q`, Escape, or +Ctrl-C to quit. Interactive mode requires a terminal of at least 44 by 12 cells. +Quote a device ID if it contains spaces. Successful help, version, listing, and +interactive exits return `0`; usage errors return `2`; capture and runtime +failures return `1`. The v0 TUI captures system output only. Microphone/device-input capture, Prism profiles and themes, file/stdin analysis, and the other visualizers remain GUI diff --git a/native/src/oscilloscope.cpp b/native/src/oscilloscope.cpp index 7d2a158..91db840 100644 --- a/native/src/oscilloscope.cpp +++ b/native/src/oscilloscope.cpp @@ -12,6 +12,7 @@ Oscilloscope::Oscilloscope() , lastFilterPitch_(200.0f) , lastTrigger_(0) , smoothedPitch_(200.0f) + , latestDetectedPitch_(0.0f) , pitchSamplesProcessed_(0) { // Initialize circular buffers @@ -144,6 +145,10 @@ OscilloscopeResult Oscilloscope::process() { result.detectedPitch = smoothedPitch_; if (!pitchLock_) { + const size_t samples = static_cast(displaySamples_); + result.triggerIndex = static_cast( + (writePos_ + OSCILLOSCOPE_BUFFER_SIZE - samples) % + OSCILLOSCOPE_BUFFER_SIZE); return result; } @@ -172,6 +177,7 @@ OscilloscopeResult Oscilloscope::process() { float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f); if (newPitch > 0.0f) { + latestDetectedPitch_ = newPitch; pitchSamplesProcessed_++; // Adaptive smoothing: fast convergence initially, then conservative @@ -307,6 +313,7 @@ void Oscilloscope::reset() { writePos_ = 0; lastTrigger_ = 0.0f; smoothedPitch_ = 200.0f; + latestDetectedPitch_ = 0.0f; lastFilterPitch_ = 200.0f; pitchSamplesProcessed_ = 0; // Reset warmup counter for fast convergence on next use diff --git a/native/src/oscilloscope.h b/native/src/oscilloscope.h index 2d57acf..15ed0c7 100644 --- a/native/src/oscilloscope.h +++ b/native/src/oscilloscope.h @@ -36,6 +36,10 @@ public: // Get current write position size_t getWritePos() const { return writePos_; } + // Latest unsmoothed detector result. The stable detectedPitch returned by + // process() remains the value used for pitch-locked triggering. + float getLatestDetectedPitch() const { return latestDetectedPitch_; } + // Get samples from circular buffer (for rendering) void getSamples(float* output, size_t startPos, size_t count) const; @@ -75,6 +79,7 @@ private: float lastTrigger_; float smoothedPitch_; + float latestDetectedPitch_; int pitchSamplesProcessed_; // Track samples for adaptive smoothing // Internal helpers diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index 78cce9c..2bca4ae 100644 --- a/tui/CMakeLists.txt +++ b/tui/CMakeLists.txt @@ -33,7 +33,13 @@ add_library(prism_tui_analysis STATIC src/cli.cpp src/dashboard_layout.cpp src/display_model.cpp + src/scope_plot_model.cpp + src/spectrum_peak_model.cpp + src/tui_settings.cpp ${PRISM_NATIVE_DIR}/spectrum.cpp + ${PRISM_NATIVE_DIR}/oscilloscope.cpp + ${PRISM_NATIVE_DIR}/vectorscope.cpp + ${PRISM_NATIVE_DIR}/multiband.cpp ${PRISM_NATIVE_DIR}/vumeter.cpp ${PRISM_NATIVE_DIR}/lufsmeter.cpp ${PRISM_NATIVE_DIR}/dsp_utils.cpp) diff --git a/tui/src/analysis_pipeline.cpp b/tui/src/analysis_pipeline.cpp index c1372cb..cad275e 100644 --- a/tui/src/analysis_pipeline.cpp +++ b/tui/src/analysis_pipeline.cpp @@ -1,15 +1,39 @@ #include "analysis_pipeline.h" #include +#include namespace Prism::Tui { +namespace { + +int normalizedOscilloscopeDisplaySamples(float sampleRate) { + constexpr int baseSamples = 2048; + constexpr float baseRateMin = 44100.0f; + constexpr float baseRateMax = 48000.0f; + float samples = static_cast(baseSamples); + if (sampleRate > 0.0f && sampleRate < baseRateMin) { + samples *= sampleRate / baseRateMin; + } else if (sampleRate > baseRateMax) { + samples *= sampleRate / baseRateMax; + } + return std::clamp( + static_cast(std::lround(samples)), + 64, + static_cast(Visualizer::OSCILLOSCOPE_BUFFER_SIZE - 1)); +} + +} // namespace AnalysisPipeline::AnalysisPipeline(float sampleRate, size_t fftSize) - : spectrum_(fftSize) { + : spectrum_(fftSize), sampleRate_(sampleRate), fftSize_(fftSize) { spectrum_.setSampleRate(sampleRate); spectrum_.setSmoothing(0.9f); vu_.setSampleRate(sampleRate); lufs_.setSampleRate(sampleRate); + oscilloscope_.setSampleRate(sampleRate); + oscilloscope_.setPitchLock(true); + oscilloscope_.setDisplaySamples(normalizedOscilloscopeDisplaySamples(sampleRate)); + vectorscope_.setSampleRate(sampleRate); } void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) { @@ -17,23 +41,96 @@ void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) { if (count == 0) { return; } - spectrum_.pushStereoSamples(chunk.left.data(), chunk.right.data(), count); - vu_.pushSamples(chunk.left.data(), chunk.right.data(), count); - lufs_.pushSamples(chunk.left.data(), chunk.right.data(), count); + const float* left = chunk.left.data(); + const float* right = chunk.right.data(); + if (inputGainLinear_ != 1.0f) { + trimmedLeftScratch_.resize(count); + trimmedRightScratch_.resize(count); + for (size_t index = 0; index < count; ++index) { + trimmedLeftScratch_[index] = chunk.left[index] * inputGainLinear_; + trimmedRightScratch_[index] = chunk.right[index] * inputGainLinear_; + } + left = trimmedLeftScratch_.data(); + right = trimmedRightScratch_.data(); + } + spectrum_.pushStereoSamples(left, right, count); + vu_.pushSamples(left, right, count); + lufs_.pushSamples(left, right, count); + monoScratch_.resize(count); + for (size_t index = 0; index < count; ++index) { + monoScratch_[index] = (left[index] + right[index]) * 0.5f; + } + oscilloscope_.pushSamples(monoScratch_.data(), count); + vectorscope_.pushMultibandSamples(left, right, count); } AnalysisFrame AnalysisPipeline::snapshot() { - return { - spectrum_.getChannelMaxMagnitudes(), - vu_.getSnapshot(), - lufs_.getSnapshot(), - }; + AnalysisFrame frame; + frame.magnitudes = spectrum_.getChannelMaxMagnitudes(); + frame.spectrumPeak = spectrumPeakTracker_.select( + frame.magnitudes, sampleRate_, fftSize_, spectrumTiltDbPerOctave_); + frame.vu = vu_.getSnapshot(); + frame.lufs = lufs_.getSnapshot(); + + const auto oscilloscopeResult = oscilloscope_.process(); + const size_t oscilloscopeSamples = static_cast( + std::max(0, oscilloscopeResult.samplesToShow)); + frame.oscilloscope.samples.resize(oscilloscopeSamples); + if (!frame.oscilloscope.samples.empty()) { + oscilloscope_.getSamplesInterpolated( + frame.oscilloscope.samples.data(), + oscilloscopeResult.triggerIndex, + frame.oscilloscope.samples.size()); + frame.oscilloscope.signalPresent = std::any_of( + frame.oscilloscope.samples.begin(), + frame.oscilloscope.samples.end(), + [](float sample) { return std::isfinite(sample) && std::abs(sample) > 0.001f; }); + } + const float latestPitch = oscilloscope_.getLatestDetectedPitch(); + if (std::isfinite(latestPitch) && latestPitch > 0.0f) { + constexpr float previousWeight = 0.6f; + displayPitch_ = displayPitch_ > 0.0f + ? displayPitch_ * previousWeight + latestPitch * (1.0f - previousWeight) + : latestPitch; + } + frame.oscilloscope.detectedPitch = displayPitch_; + + frame.vectorscope.multibandPoints.resize( + kVectorscopeDisplayPoints * Visualizer::MULTIBAND_POINT_STRIDE); + frame.vectorscope.pointCount = vectorscope_.getMultibandPoints( + frame.vectorscope.multibandPoints.data(), + kVectorscopeDisplayPoints); + frame.vectorscope.multibandPoints.resize( + frame.vectorscope.pointCount * Visualizer::MULTIBAND_POINT_STRIDE); + return frame; } void AnalysisPipeline::reset() { spectrum_.reset(); vu_.reset(); lufs_.reset(); + oscilloscope_.reset(); + vectorscope_.reset(); + spectrumPeakTracker_.reset(); + monoScratch_.clear(); + trimmedLeftScratch_.clear(); + trimmedRightScratch_.clear(); + displayPitch_ = 0.0f; +} + +void AnalysisPipeline::setInputTrimDb(float db) { + const float normalized = std::clamp( + std::isfinite(db) ? db : 0.0f, -12.0f, 12.0f); + inputGainLinear_ = std::pow(10.0f, normalized / 20.0f); +} + +void AnalysisPipeline::setSpectrumTilt(float dbPerOctave) { + spectrumTiltDbPerOctave_ = std::clamp( + std::isfinite(dbPerOctave) ? dbPerOctave : 2.0f, -2.0f, 8.0f); +} + +void AnalysisPipeline::setOscilloscopePitchLock(bool enabled) { + oscilloscope_.setPitchLock(enabled); } size_t drainCapture(Prism::Capture::SystemAudioCapture& capture, diff --git a/tui/src/analysis_pipeline.h b/tui/src/analysis_pipeline.h index ac1c52d..146b386 100644 --- a/tui/src/analysis_pipeline.h +++ b/tui/src/analysis_pipeline.h @@ -1,20 +1,39 @@ #pragma once #include "lufsmeter.h" +#include "oscilloscope.h" #include "spectrum.h" +#include "spectrum_peak_model.h" #include "system_audio_capture.h" +#include "vectorscope.h" #include "vumeter.h" #include +#include namespace Prism::Tui { constexpr size_t kDefaultFftSize = 4096; +constexpr size_t kVectorscopeDisplayPoints = 4096; + +struct OscilloscopeFrame { + std::vector samples; + float detectedPitch = 0.0f; + bool signalPresent = false; +}; + +struct VectorscopeFrame { + std::vector multibandPoints; + size_t pointCount = 0; +}; struct AnalysisFrame { std::vector magnitudes; + std::optional spectrumPeak; Visualizer::VUMeterSnapshot vu{}; Visualizer::LUFSMeterSnapshot lufs{}; + OscilloscopeFrame oscilloscope; + VectorscopeFrame vectorscope; }; class AnalysisPipeline { @@ -24,11 +43,25 @@ public: void process(const Prism::Capture::AudioChunk& chunk); AnalysisFrame snapshot(); void reset(); + void setInputTrimDb(float db); + void setSpectrumTilt(float dbPerOctave); + void setOscilloscopePitchLock(bool enabled); private: Visualizer::Spectrum spectrum_; Visualizer::VUMeterAnalyzer vu_; Visualizer::LUFSMeterAnalyzer lufs_; + Visualizer::Oscilloscope oscilloscope_; + Visualizer::Vectorscope vectorscope_; + SpectrumPeakTracker spectrumPeakTracker_; + std::vector monoScratch_; + std::vector trimmedLeftScratch_; + std::vector trimmedRightScratch_; + float sampleRate_ = 48000.0f; + size_t fftSize_ = kDefaultFftSize; + float inputGainLinear_ = 1.0f; + float spectrumTiltDbPerOctave_ = 2.0f; + float displayPitch_ = 0.0f; }; size_t drainCapture(Prism::Capture::SystemAudioCapture& capture, diff --git a/tui/src/cli.cpp b/tui/src/cli.cpp index c168cf8..da40a1d 100644 --- a/tui/src/cli.cpp +++ b/tui/src/cli.cpp @@ -60,8 +60,10 @@ std::string usageText() { "Controls:\n" " Tab / Shift-Tab Focus the next or previous panel.\n" " Enter Expand the focused panel or restore the dashboard.\n" + " s Open settings for the focused scope.\n" " l Cycle automatic, stacked, and column layouts.\n" - " 1 / 2 Focus Spectrum or Levels.\n" + " v Cycle vectorscope display modes.\n" + " 1 / 2 / 3 / 4 Focus Spectrum, Oscilloscope, Vectorscope, or Levels.\n" " r Reset analyzers and integrated loudness.\n" " q / Esc / Ctrl-C Quit.\n"; } diff --git a/tui/src/dashboard_layout.cpp b/tui/src/dashboard_layout.cpp index 0e9a967..da8076c 100644 --- a/tui/src/dashboard_layout.cpp +++ b/tui/src/dashboard_layout.cpp @@ -16,6 +16,10 @@ MinimumSize panelMinimumSize(PanelId panel) { switch (panel) { case PanelId::Spectrum: return {30, 5}; + case PanelId::Oscilloscope: + return {30, 5}; + case PanelId::Vectorscope: + return {30, 8}; case PanelId::Levels: return {30, 5}; } @@ -134,13 +138,15 @@ void resolveNode(const LayoutNode& node, LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) { constexpr int minimumColumnsWidth = 72; - if (requested == LayoutPreset::Columns && width < minimumColumnsWidth) { + constexpr int minimumColumnsHeight = 18; + if (requested == LayoutPreset::Columns && + (width < minimumColumnsWidth || height < minimumColumnsHeight)) { return LayoutPreset::Stacked; } if (requested != LayoutPreset::Automatic) { return requested; } - return width >= 96 && height >= 28 + return width >= minimumColumnsWidth && height >= minimumColumnsHeight ? LayoutPreset::Columns : LayoutPreset::Stacked; } @@ -148,8 +154,14 @@ LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) { LayoutNode makeRoot(LayoutPreset preset) { if (preset == LayoutPreset::Columns) { return LayoutNode::split(SplitAxis::Columns, { - LayoutNode::leaf(PanelId::Spectrum, 4), - LayoutNode::leaf(PanelId::Levels, 1), + LayoutNode::split(SplitAxis::Rows, { + LayoutNode::leaf(PanelId::Spectrum, 3), + LayoutNode::leaf(PanelId::Oscilloscope, 2), + }, 3), + LayoutNode::split(SplitAxis::Rows, { + LayoutNode::leaf(PanelId::Vectorscope, 1), + LayoutNode::leaf(PanelId::Levels, 1), + }, 1), }); } return LayoutNode::split(SplitAxis::Rows, { @@ -223,19 +235,50 @@ std::string layoutPresetName(LayoutPreset preset) { } std::vector panelOrder() { - return {PanelId::Spectrum, PanelId::Levels}; + return { + PanelId::Spectrum, + PanelId::Oscilloscope, + PanelId::Vectorscope, + PanelId::Levels, + }; } PanelId nextPanel(PanelId panel, bool reverse) { - const auto panels = panelOrder(); + return nextPanel(panel, panelOrder(), reverse); +} + +PanelId nextPanel(PanelId panel, + const std::vector& panels, + bool reverse) { + if (panels.empty()) { + return panel; + } const auto found = std::find(panels.begin(), panels.end(), panel); - const size_t index = found == panels.end() - ? 0 - : static_cast(std::distance(panels.begin(), found)); + if (found == panels.end()) { + return reverse ? panels.back() : panels.front(); + } + const size_t index = static_cast(std::distance(panels.begin(), found)); if (reverse) { return panels[(index + panels.size() - 1) % panels.size()]; } return panels[(index + 1) % panels.size()]; } +std::vector visiblePanelOrder(const DashboardLayout& layout) { + std::vector result; + for (const auto panel : panelOrder()) { + if (layoutContainsPanel(layout, panel)) { + result.push_back(panel); + } + } + return result; +} + +bool layoutContainsPanel(const DashboardLayout& layout, PanelId panel) { + return std::any_of( + layout.panels.begin(), + layout.panels.end(), + [panel](const PanelRect& rect) { return rect.panel == panel; }); +} + } // namespace Prism::Tui diff --git a/tui/src/dashboard_layout.h b/tui/src/dashboard_layout.h index 206a585..24712f9 100644 --- a/tui/src/dashboard_layout.h +++ b/tui/src/dashboard_layout.h @@ -8,6 +8,8 @@ namespace Prism::Tui { enum class PanelId { Spectrum, + Oscilloscope, + Vectorscope, Levels, }; @@ -62,5 +64,10 @@ LayoutPreset nextLayoutPreset(LayoutPreset preset); std::string layoutPresetName(LayoutPreset preset); std::vector panelOrder(); PanelId nextPanel(PanelId panel, bool reverse = false); +PanelId nextPanel(PanelId panel, + const std::vector& panels, + bool reverse = false); +std::vector visiblePanelOrder(const DashboardLayout& layout); +bool layoutContainsPanel(const DashboardLayout& layout, PanelId panel); } // namespace Prism::Tui diff --git a/tui/src/scope_plot_model.cpp b/tui/src/scope_plot_model.cpp new file mode 100644 index 0000000..7324e52 --- /dev/null +++ b/tui/src/scope_plot_model.cpp @@ -0,0 +1,230 @@ +#include "scope_plot_model.h" + +#include "multiband.h" + +#include +#include + +namespace Prism::Tui { +namespace { + +constexpr float kInverseSqrtTwo = 0.7071067811865475f; + +bool isUnipolar(VectorscopeMode mode) { + return mode == VectorscopeMode::PolarUnipolar || + mode == VectorscopeMode::LinearUnipolar; +} + +bool isPolar(VectorscopeMode mode) { + return mode == VectorscopeMode::PolarUnipolar || + mode == VectorscopeMode::PolarBipolar; +} + +bool transformVectorscopePoint(float left, + float right, + VectorscopeMode mode, + float& x, + float& y) { + if (mode == VectorscopeMode::Lissajous) { + x = right; + y = left; + return true; + } + + const float mid = (left + right) * kInverseSqrtTwo; + const float side = (right - left) * kInverseSqrtTwo; + if (isUnipolar(mode) && mid < 0.0f) { + return false; + } + + if (isPolar(mode)) { + const float amplitudeSquared = mid * mid + side * side; + if (amplitudeSquared < 1e-12f) { + x = 0.0f; + y = 0.0f; + return true; + } + const float amplitude = std::sqrt(amplitudeSquared); + const float scaledAmplitude = std::pow(amplitude, 0.35f); + const float factor = scaledAmplitude / amplitude; + x = side * factor; + y = mid * factor; + return true; + } + + x = side; + y = mid; + return true; +} + +} // namespace + +std::vector buildOscilloscopePlot(const std::vector& samples, + int pixelWidth, + int pixelHeight) { + if (samples.empty() || pixelWidth <= 0 || pixelHeight <= 0) { + return {}; + } + + std::vector points; + points.reserve(static_cast(pixelWidth)); + const float sampleSpan = static_cast(samples.size() - 1); + const float xSpan = static_cast(std::max(1, pixelWidth - 1)); + const float ySpan = static_cast(std::max(0, pixelHeight - 1)); + for (int x = 0; x < pixelWidth; ++x) { + const float samplePosition = static_cast(x) / xSpan * sampleSpan; + const size_t first = std::min( + samples.size() - 1, + static_cast(std::floor(samplePosition))); + const size_t second = std::min(samples.size() - 1, first + 1); + const float fraction = samplePosition - static_cast(first); + const float firstSample = std::isfinite(samples[first]) ? samples[first] : 0.0f; + const float secondSample = std::isfinite(samples[second]) ? samples[second] : 0.0f; + const float sample = std::clamp( + firstSample + (secondSample - firstSample) * fraction, + -1.0f, + 1.0f); + const int y = static_cast(std::lround( + (1.0f - sample) * 0.5f * ySpan)); + points.push_back({x, std::clamp(y, 0, pixelHeight - 1)}); + } + return points; +} + +int oscilloscopeZeroY(int pixelHeight) { + if (pixelHeight <= 0) { + return 0; + } + return static_cast(std::lround( + static_cast(pixelHeight - 1) * 0.5f)); +} + +VectorscopeBands buildVectorscopePlot(const std::vector& multibandPoints, + size_t pointCount, + int pixelWidth, + int pixelHeight, + VectorscopeMode mode, + int densityDivisor) { + VectorscopeBands result; + if (pixelWidth <= 0 || pixelHeight <= 0 || multibandPoints.empty()) { + return result; + } + + const size_t count = std::min( + pointCount, + multibandPoints.size() / Visualizer::MULTIBAND_POINT_STRIDE); + const size_t pixelCapacity = static_cast(pixelWidth) * + static_cast(pixelHeight); + const size_t sampleBudget = std::min( + count, + std::max(64, pixelCapacity / + static_cast(std::max(1, densityDivisor)))); + const size_t stride = sampleBudget > 0 + ? std::max(1, (count + sampleBudget - 1) / sampleBudget) + : 1; + const size_t firstIndex = count > 0 ? (count - 1) % stride : 0; + for (auto& band : result) { + band.reserve(sampleBudget); + } + + const auto layout = getVectorscopePlotLayout(pixelWidth, pixelHeight, mode); + + for (size_t index = firstIndex; index < count; index += stride) { + const size_t base = index * Visualizer::MULTIBAND_POINT_STRIDE; + const float intensity = count > 1 + ? 0.15f + 0.85f * static_cast(index) / + static_cast(count - 1) + : 1.0f; + for (size_t band = 0; band < result.size(); ++band) { + const float leftValue = multibandPoints[base + band * 2]; + const float rightValue = multibandPoints[base + band * 2 + 1]; + const float left = std::isfinite(leftValue) + ? std::clamp(leftValue, -1.25f, 1.25f) + : 0.0f; + const float right = std::isfinite(rightValue) + ? std::clamp(rightValue, -1.25f, 1.25f) + : 0.0f; + if (std::abs(left) + std::abs(right) < 1e-5f) { + continue; + } + float transformedX = 0.0f; + float transformedY = 0.0f; + if (!transformVectorscopePoint( + left, right, mode, transformedX, transformedY)) { + continue; + } + const int x = static_cast(std::lround( + static_cast(layout.centerX) + + transformedX * static_cast(layout.radius))); + const int y = static_cast(std::lround( + static_cast(layout.centerY) - + transformedY * static_cast(layout.radius))); + result[band].push_back({ + std::clamp(x, 0, pixelWidth - 1), + std::clamp(y, 0, pixelHeight - 1), + intensity, + }); + } + } + return result; +} + +VectorscopePlotLayout getVectorscopePlotLayout(int pixelWidth, + int pixelHeight, + VectorscopeMode mode) { + VectorscopePlotLayout layout; + if (pixelWidth <= 0 || pixelHeight <= 0) { + return layout; + } + + layout.centerX = (pixelWidth - 1) / 2; + layout.unipolar = isUnipolar(mode); + if (layout.unipolar) { + const int margin = std::max(1, pixelHeight / 25); + layout.centerY = pixelHeight - 1 - margin; + layout.radius = static_cast(std::lround( + static_cast(std::min( + pixelWidth / 2, + std::max(0, layout.centerY))) * 0.88f)); + } else { + layout.centerY = (pixelHeight - 1) / 2; + layout.radius = static_cast(std::lround( + static_cast(std::min(pixelWidth, pixelHeight)) * 0.45f)); + } + layout.radius = std::max(0, layout.radius); + return layout; +} + +VectorscopeMode nextVectorscopeMode(VectorscopeMode mode) { + switch (mode) { + case VectorscopeMode::Lissajous: + return VectorscopeMode::PolarUnipolar; + case VectorscopeMode::PolarUnipolar: + return VectorscopeMode::PolarBipolar; + case VectorscopeMode::PolarBipolar: + return VectorscopeMode::LinearUnipolar; + case VectorscopeMode::LinearUnipolar: + return VectorscopeMode::LinearBipolar; + case VectorscopeMode::LinearBipolar: + return VectorscopeMode::Lissajous; + } + return VectorscopeMode::Lissajous; +} + +const char* vectorscopeModeName(VectorscopeMode mode) { + switch (mode) { + case VectorscopeMode::Lissajous: + return "Lissajous"; + case VectorscopeMode::PolarUnipolar: + return "Polar +"; + case VectorscopeMode::PolarBipolar: + return "Polar ±"; + case VectorscopeMode::LinearUnipolar: + return "Linear +"; + case VectorscopeMode::LinearBipolar: + return "Linear ±"; + } + return "Lissajous"; +} + +} // namespace Prism::Tui diff --git a/tui/src/scope_plot_model.h b/tui/src/scope_plot_model.h new file mode 100644 index 0000000..01cebf2 --- /dev/null +++ b/tui/src/scope_plot_model.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +namespace Prism::Tui { + +struct PlotPoint { + int x = 0; + int y = 0; + float intensity = 1.0f; +}; + +enum class VectorscopeMode { + Lissajous, + PolarUnipolar, + PolarBipolar, + LinearUnipolar, + LinearBipolar, +}; + +struct VectorscopePlotLayout { + int centerX = 0; + int centerY = 0; + int radius = 0; + bool unipolar = false; +}; + +using VectorscopeBands = std::array, 3>; + +std::vector buildOscilloscopePlot(const std::vector& samples, + int pixelWidth, + int pixelHeight); +int oscilloscopeZeroY(int pixelHeight); + +VectorscopeBands buildVectorscopePlot(const std::vector& multibandPoints, + size_t pointCount, + int pixelWidth, + int pixelHeight, + VectorscopeMode mode = VectorscopeMode::Lissajous, + int densityDivisor = 6); + +VectorscopePlotLayout getVectorscopePlotLayout(int pixelWidth, + int pixelHeight, + VectorscopeMode mode); +VectorscopeMode nextVectorscopeMode(VectorscopeMode mode); +const char* vectorscopeModeName(VectorscopeMode mode); + +} // namespace Prism::Tui diff --git a/tui/src/spectrum_peak_model.cpp b/tui/src/spectrum_peak_model.cpp new file mode 100644 index 0000000..cba52a0 --- /dev/null +++ b/tui/src/spectrum_peak_model.cpp @@ -0,0 +1,178 @@ +#include "spectrum_peak_model.h" + +#include +#include +#include +#include +#include +#include + +namespace Prism::Tui { +namespace { + +constexpr float kMinFrequency = 20.0f; +constexpr float kMaxFrequency = 20000.0f; +constexpr float kTiltReferenceHz = 1000.0f; +constexpr float kMaximumStickyDistanceOctaves = 0.5f; +constexpr float kSwitchThresholdDb = 4.0f; +constexpr float kLowFrequencyBiasDbPerOctave = 0.75f; +constexpr float kUpwardSwitchThresholdDb = 2.0f; +constexpr float kSilenceThresholdDbfs = -90.0f; + +float finiteMagnitude(const std::vector& magnitudes, size_t index) { + return std::isfinite(magnitudes[index]) ? magnitudes[index] : -120.0f; +} + +float frequencyForBin(float bin, float sampleRate, size_t fftSize) { + return bin * sampleRate / static_cast(fftSize); +} + +float displayDb(float dbfs, float frequencyHz, float tiltDbPerOctave) { + return dbfs + tiltDbPerOctave * std::log2( + std::max(1.0f, frequencyHz) / kTiltReferenceHz); +} + +float score(float dbfs, float frequencyHz, float tiltDbPerOctave) { + const float tilted = displayDb(dbfs, frequencyHz, tiltDbPerOctave); + const float octaveOffset = std::max( + 0.0f, std::log2(std::max(1.0f, frequencyHz) / kMinFrequency)); + return tilted - octaveOffset * kLowFrequencyBiasDbPerOctave; +} + +SpectrumPeakInfo peakAt(const std::vector& magnitudes, + size_t bin, + float sampleRate, + size_t fftSize) { + float offset = 0.0f; + float dbfs = finiteMagnitude(magnitudes, bin); + if (bin > 0 && bin + 1 < magnitudes.size()) { + const float previous = finiteMagnitude(magnitudes, bin - 1); + const float current = dbfs; + const float next = finiteMagnitude(magnitudes, bin + 1); + const float denominator = previous - 2.0f * current + next; + if (std::abs(denominator) > 1.0e-9f) { + offset = std::clamp( + 0.5f * (previous - next) / denominator, -0.5f, 0.5f); + dbfs = current - 0.25f * (previous - next) * offset; + } + } + const float frequency = frequencyForBin( + static_cast(bin) + offset, sampleRate, fftSize); + return {dbfs, frequency, formatSpectrumPitch(frequency)}; +} + +} // namespace + +std::string formatSpectrumPitch(float frequencyHz) { + if (!std::isfinite(frequencyHz) || frequencyHz <= 0.0f) { + return "--"; + } + static constexpr std::array noteNames = { + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + }; + const float midi = 69.0f + 12.0f * std::log2(frequencyHz / 440.0f); + const int nearest = static_cast(std::lround(midi)); + const int cents = static_cast(std::lround((midi - nearest) * 100.0f)); + const int noteIndex = ((nearest % 12) + 12) % 12; + const int octave = static_cast(std::floor(static_cast(nearest) / 12.0f)) - 1; + std::ostringstream output; + output << noteNames[static_cast(noteIndex)] << octave << ' ' + << (cents > 0 ? "+" : "") << cents << 'c'; + return output.str(); +} + +std::optional SpectrumPeakTracker::select( + const std::vector& magnitudes, + float sampleRate, + size_t fftSize, + float tiltDbPerOctave) { + if (magnitudes.size() < 3 || sampleRate <= 0.0f || fftSize == 0) { + previous_.reset(); + return std::nullopt; + } + + const float binWidth = sampleRate / static_cast(fftSize); + const size_t firstBin = std::clamp( + static_cast(std::ceil(kMinFrequency / binWidth)), + 1, + magnitudes.size() - 2); + const size_t lastBin = std::clamp( + static_cast(std::floor( + std::min(kMaxFrequency, sampleRate * 0.5f) / binWidth)), + firstBin, + magnitudes.size() - 2); + + std::vector candidates; + for (size_t bin = firstBin; bin <= lastBin; ++bin) { + const float previous = finiteMagnitude(magnitudes, bin - 1); + const float current = finiteMagnitude(magnitudes, bin); + const float next = finiteMagnitude(magnitudes, bin + 1); + if (current >= previous && current >= next && + (current > previous || current > next)) { + candidates.push_back(bin); + } + } + if (candidates.empty()) { + const auto best = std::max_element( + magnitudes.begin() + static_cast(firstBin), + magnitudes.begin() + static_cast(lastBin + 1)); + candidates.push_back(static_cast( + std::distance(magnitudes.begin(), best))); + } + + const auto candidateScore = [&](size_t bin) { + const float frequency = frequencyForBin( + static_cast(bin), sampleRate, fftSize); + return score(finiteMagnitude(magnitudes, bin), frequency, tiltDbPerOctave); + }; + const auto better = [&](size_t left, size_t right) { + const float leftScore = candidateScore(left); + const float rightScore = candidateScore(right); + if (leftScore != rightScore) return leftScore > rightScore; + const float leftDb = finiteMagnitude(magnitudes, left); + const float rightDb = finiteMagnitude(magnitudes, right); + return leftDb != rightDb ? leftDb > rightDb : left < right; + }; + + size_t strongest = candidates.front(); + for (size_t candidate : candidates) { + if (better(candidate, strongest)) strongest = candidate; + } + size_t selected = strongest; + if (previous_ && previous_->frequencyHz > 0.0f) { + std::optional sticky; + for (size_t candidate : candidates) { + const float frequency = frequencyForBin( + static_cast(candidate), sampleRate, fftSize); + const float distance = std::abs(std::log2( + frequency / previous_->frequencyHz)); + if (distance <= kMaximumStickyDistanceOctaves && + (!sticky || better(candidate, *sticky))) { + sticky = candidate; + } + } + if (sticky) { + const float upwardPenalty = strongest > *sticky + ? kUpwardSwitchThresholdDb + : 0.0f; + if (candidateScore(strongest) < + candidateScore(*sticky) + kSwitchThresholdDb + upwardPenalty) { + selected = *sticky; + } + } + } + + SpectrumPeakInfo result = peakAt(magnitudes, selected, sampleRate, fftSize); + if (!std::isfinite(result.dbfs) || result.dbfs <= kSilenceThresholdDbfs) { + previous_.reset(); + return std::nullopt; + } + previous_ = result; + return result; +} + +void SpectrumPeakTracker::reset() { + previous_.reset(); +} + +} // namespace Prism::Tui diff --git a/tui/src/spectrum_peak_model.h b/tui/src/spectrum_peak_model.h new file mode 100644 index 0000000..a7c94b7 --- /dev/null +++ b/tui/src/spectrum_peak_model.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +namespace Prism::Tui { + +struct SpectrumPeakInfo { + float dbfs = -100.0f; + float frequencyHz = 0.0f; + std::string pitch; +}; + +std::string formatSpectrumPitch(float frequencyHz); + +class SpectrumPeakTracker { +public: + std::optional select(const std::vector& magnitudes, + float sampleRate, + size_t fftSize, + float tiltDbPerOctave); + void reset(); + +private: + std::optional previous_; +}; + +} // namespace Prism::Tui diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index 670ac10..0b17cdc 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -3,21 +3,27 @@ #include "analysis_pipeline.h" #include "dashboard_layout.h" #include "display_model.h" +#include "scope_plot_model.h" #include "snapshot_store.h" +#include "tui_settings.h" #include #include #include +#include #include #include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -34,7 +40,11 @@ namespace Prism::Tui { namespace { constexpr auto kCapturePollInterval = std::chrono::milliseconds(2); -constexpr auto kDisplayFrameInterval = std::chrono::milliseconds(33); + +std::chrono::microseconds displayFrameInterval(int framesPerSecond) { + return std::chrono::microseconds( + 1000000 / std::max(1, framesPerSecond)); +} volatile std::sig_atomic_t signalRequested = 0; @@ -61,8 +71,11 @@ private: struct DisplayFrame { std::vector magnitudes; + std::optional spectrumPeak; Visualizer::VUMeterSnapshot vu{}; Visualizer::LUFSMeterSnapshot lufs{}; + OscilloscopeFrame oscilloscope; + VectorscopeFrame vectorscope; double sampleRate = 48000.0; std::string backend; std::string device; @@ -71,11 +84,30 @@ struct DisplayFrame { struct InterfaceState { PanelId focusedPanel = PanelId::Spectrum; - LayoutPreset layoutPreset = LayoutPreset::Automatic; std::optional expandedPanel; + TuiSettings settings; + bool settingsOpen = false; + SettingsPage settingsPage = SettingsPage::Home; + size_t settingsHomeSelection = 0; + std::array settingsSelections{}; + std::string settingsStatus; }; -std::string makeCaptureStatus(const DisplayFrame& frame, bool compact) { +size_t settingsPageIndex(SettingsPage page) { + return static_cast(page); +} + +size_t& settingsSelection(InterfaceState& state) { + return state.settingsSelections[settingsPageIndex(state.settingsPage)]; +} + +const size_t& settingsSelection(const InterfaceState& state) { + return state.settingsSelections[settingsPageIndex(state.settingsPage)]; +} + +std::string makeCaptureStatus(const DisplayFrame& frame, + const TuiSettings& settings, + bool compact) { std::ostringstream sampleRate; const double kilohertz = frame.sampleRate / 1000.0; sampleRate << std::fixed << std::setprecision( @@ -85,28 +117,68 @@ std::string makeCaptureStatus(const DisplayFrame& frame, bool compact) { footer += frame.device + " • "; } footer += sampleRate.str() + " kHz"; + if (settings.inputTrimDb != 0.0f) { + std::ostringstream trim; + trim << " • trim " << (settings.inputTrimDb > 0.0f ? "+" : "") + << std::fixed << std::setprecision(1) << settings.inputTrimDb << " dB"; + footer += trim.str(); + } if (frame.captureOverrun) { footer += " • capture overrun"; } return footer; } +std::string formatSpectrumPeak(const SpectrumPeakInfo& peak, int width) { + std::ostringstream frequency; + frequency << std::fixed << std::setprecision( + peak.frequencyHz < 1000.0f ? 1 : 0) << peak.frequencyHz << " Hz"; + if (width < 42) { + return frequency.str(); + } + std::ostringstream db; + db << std::fixed << std::setprecision(1) << peak.dbfs << " dBFS"; + if (width < 58) { + return db.str() + " • " + frequency.str(); + } + return db.str() + " • " + frequency.str() + " • " + peak.pitch; +} + std::string panelName(PanelId panel) { switch (panel) { case PanelId::Spectrum: return "Spectrum"; + case PanelId::Oscilloscope: + return "Oscilloscope"; + case PanelId::Vectorscope: + return "Vectorscope"; case PanelId::Levels: return "Levels"; } return "Panel"; } -ftxui::Element panelTitle(PanelId panel, bool focused) { +std::string panelNumber(PanelId panel) { + switch (panel) { + case PanelId::Spectrum: + return "1"; + case PanelId::Oscilloscope: + return "2"; + case PanelId::Vectorscope: + return "3"; + case PanelId::Levels: + return "4"; + } + return "?"; +} + +ftxui::Element panelTitle(PanelId panel, + bool focused, + const std::string& detail = {}) { using namespace ftxui; - const std::string number = panel == PanelId::Spectrum ? "1" : "2"; - std::string label = " " + number + " " + panelName(panel); - if (panel == PanelId::Spectrum) { - label += " • FFT " + std::to_string(kDefaultFftSize); + std::string label = " " + panelNumber(panel) + " " + panelName(panel); + if (!detail.empty()) { + label += " • " + detail; } label += " "; auto title = text(label); @@ -123,7 +195,8 @@ ftxui::Element stylePanel(ftxui::Element content, bool focused) { ftxui::Element renderSpectrumPanel(const DisplayFrame& frame, int width, int height, - bool focused) { + bool focused, + const TuiSettings& settings) { using namespace ftxui; const size_t contentWidth = static_cast(std::max(1, width - 2)); const size_t contentHeight = static_cast(std::max(1, height - 2)); @@ -132,6 +205,7 @@ ftxui::Element renderSpectrumPanel(const DisplayFrame& frame, SpectrumProjectionOptions projectionOptions; projectionOptions.sampleRate = static_cast(frame.sampleRate); projectionOptions.maxFrequency = std::min(20000.0f, projectionOptions.sampleRate * 0.5f); + projectionOptions.tiltDbPerOctave = settings.spectrumTiltDbPerOctave; const auto projected = projectSpectrum( frame.magnitudes, kDefaultFftSize, @@ -150,14 +224,250 @@ ftxui::Element renderSpectrumPanel(const DisplayFrame& frame, color(Color::GrayDark)); } + const std::string detail = settings.spectrumPeakReadout && frame.spectrumPeak + ? formatSpectrumPeak(*frame.spectrumPeak, width) + : "FFT " + std::to_string(kDefaultFftSize); auto panel = window( - panelTitle(PanelId::Spectrum, focused), + panelTitle( + PanelId::Spectrum, + focused, + detail), vbox(std::move(spectrumElements))); return stylePanel(std::move(panel), focused) | size(WIDTH, EQUAL, std::max(1, width)) | size(HEIGHT, EQUAL, std::max(1, height)); } +ftxui::Element renderOscilloscopePanel(const DisplayFrame& frame, + int width, + int height, + bool focused, + const TuiSettings& settings) { + using namespace ftxui; + std::string detail = settings.oscilloscopePitchLock ? "Pitch lock" : "Free run"; + if (settings.oscilloscopeFrequencyReadout && + frame.oscilloscope.signalPresent && + std::isfinite(frame.oscilloscope.detectedPitch) && + frame.oscilloscope.detectedPitch > 0.0f) { + detail = std::to_string(static_cast( + std::lround(frame.oscilloscope.detectedPitch))) + " Hz" + + (settings.oscilloscopePitchLock ? " lock" : ""); + } + + auto plot = canvas([ + samples = frame.oscilloscope.samples, + signalPresent = frame.oscilloscope.signalPresent, + traceWeight = settings.oscilloscopeTraceWeight + ](Canvas& surface) { + const int canvasWidth = surface.width(); + const int canvasHeight = surface.height(); + if (canvasWidth <= 0 || canvasHeight <= 0) { + return; + } + + const int centerY = oscilloscopeZeroY(canvasHeight); + surface.DrawPointLine( + 0, centerY, canvasWidth - 1, centerY, Color::GrayDark); + if (!signalPresent) { + return; + } + const auto points = buildOscilloscopePlot( + samples, canvasWidth, canvasHeight); + for (size_t index = 1; index < points.size(); ++index) { + for (int thickness = 0; thickness < traceWeight; ++thickness) { + surface.DrawPointLine( + points[index - 1].x, + std::clamp(points[index - 1].y + thickness, 0, canvasHeight - 1), + points[index].x, + std::clamp(points[index].y + thickness, 0, canvasHeight - 1), + Color::CyanLight); + } + } + }) | flex; + + auto panel = window( + panelTitle(PanelId::Oscilloscope, focused, detail), + std::move(plot)); + return stylePanel(std::move(panel), focused) | + size(WIDTH, EQUAL, std::max(1, width)) | + size(HEIGHT, EQUAL, std::max(1, height)); +} + +void drawVectorscopeGrid(ftxui::Canvas& surface, VectorscopeMode mode) { + using ftxui::Color; + const auto layout = getVectorscopePlotLayout( + surface.width(), surface.height(), mode); + if (layout.radius <= 0 || mode == VectorscopeMode::Lissajous) { + return; + } + + const auto grid = Color::RGB(76, 82, 88); + const auto guide = Color::RGB(48, 53, 58); + const int left = layout.centerX - layout.radius; + const int right = layout.centerX + layout.radius; + const int top = layout.centerY - layout.radius; + const int bottom = layout.centerY + layout.radius; + const int halfRadius = std::max(1, layout.radius / 2); + const int diagonal = static_cast(std::lround( + static_cast(layout.radius) * 0.70710678f)); + const auto drawTriangle = [&](int radius, const Color& color) { + surface.DrawPointLine( + layout.centerX, layout.centerY - radius, + layout.centerX - radius, layout.centerY, color); + surface.DrawPointLine( + layout.centerX - radius, layout.centerY, + layout.centerX + radius, layout.centerY, color); + surface.DrawPointLine( + layout.centerX + radius, layout.centerY, + layout.centerX, layout.centerY - radius, color); + }; + const auto drawDiamond = [&](int radius, const Color& color) { + surface.DrawPointLine( + layout.centerX, layout.centerY - radius, + layout.centerX + radius, layout.centerY, color); + surface.DrawPointLine( + layout.centerX + radius, layout.centerY, + layout.centerX, layout.centerY + radius, color); + surface.DrawPointLine( + layout.centerX, layout.centerY + radius, + layout.centerX - radius, layout.centerY, color); + surface.DrawPointLine( + layout.centerX - radius, layout.centerY, + layout.centerX, layout.centerY - radius, color); + }; + switch (mode) { + case VectorscopeMode::PolarUnipolar: + surface.DrawPointCircle( + layout.centerX, layout.centerY, layout.radius, grid); + surface.DrawPointCircle( + layout.centerX, layout.centerY, halfRadius, guide); + surface.DrawPointLine( + layout.centerX, top, layout.centerX, layout.centerY, grid); + surface.DrawPointLine( + layout.centerX, layout.centerY, + layout.centerX - diagonal, layout.centerY - diagonal, guide); + surface.DrawPointLine( + layout.centerX, layout.centerY, + layout.centerX + diagonal, layout.centerY - diagonal, guide); + break; + case VectorscopeMode::PolarBipolar: + surface.DrawPointCircle( + layout.centerX, layout.centerY, layout.radius, grid); + surface.DrawPointCircle( + layout.centerX, layout.centerY, halfRadius, guide); + surface.DrawPointLine( + layout.centerX, top, layout.centerX, bottom, grid); + surface.DrawPointLine( + left, layout.centerY, right, layout.centerY, guide); + surface.DrawPointLine( + layout.centerX - diagonal, layout.centerY - diagonal, + layout.centerX + diagonal, layout.centerY + diagonal, guide); + surface.DrawPointLine( + layout.centerX + diagonal, layout.centerY - diagonal, + layout.centerX - diagonal, layout.centerY + diagonal, guide); + break; + case VectorscopeMode::LinearUnipolar: + drawTriangle(layout.radius, grid); + drawTriangle(halfRadius, guide); + surface.DrawPointLine( + layout.centerX, top, layout.centerX, layout.centerY, grid); + break; + case VectorscopeMode::LinearBipolar: + drawDiamond(layout.radius, grid); + drawDiamond(halfRadius, guide); + surface.DrawPointLine( + layout.centerX, top, layout.centerX, bottom, grid); + surface.DrawPointLine( + left, layout.centerY, right, layout.centerY, guide); + break; + case VectorscopeMode::Lissajous: + break; + } +} + +ftxui::Element renderVectorscopePanel(const DisplayFrame& frame, + int width, + int height, + bool focused, + const TuiSettings& settings) { + using namespace ftxui; + const VectorscopeMode mode = settings.vectorscopeMode; + const int densityDivisor = settings.vectorscopeDetail == VectorscopeDetail::Balanced + ? 10 + : settings.vectorscopeDetail == VectorscopeDetail::Maximum ? 3 : 6; + auto plot = canvas([ + multibandPoints = frame.vectorscope.multibandPoints, + pointCount = frame.vectorscope.pointCount, + mode, + showGuides = settings.vectorscopeGuides, + densityDivisor + ](Canvas& surface) { + const int canvasWidth = surface.width(); + const int canvasHeight = surface.height(); + if (canvasWidth <= 0 || canvasHeight <= 0) { + return; + } + + if (showGuides) { + drawVectorscopeGrid(surface, mode); + } + + const auto bands = buildVectorscopePlot( + multibandPoints, + pointCount, + canvasWidth, + canvasHeight, + mode, + densityDivisor); + constexpr int ageBuckets = 8; + const std::array, 3> baseColors = {{ + {{255, 68, 68}}, + {{68, 221, 68}}, + {{68, 136, 255}}, + }}; + std::array, 3> colors; + for (size_t band = 0; band < colors.size(); ++band) { + for (int bucket = 0; bucket < ageBuckets; ++bucket) { + const float brightness = 0.3f + 0.7f * + static_cast(bucket + 1) / + static_cast(ageBuckets); + colors[band][bucket] = Color::RGB( + static_cast(std::lround( + static_cast(baseColors[band][0]) * brightness)), + static_cast(std::lround( + static_cast(baseColors[band][1]) * brightness)), + static_cast(std::lround( + static_cast(baseColors[band][2]) * brightness))); + } + } + for (int bucket = 0; bucket < ageBuckets; ++bucket) { + for (size_t band = 0; band < bands.size(); ++band) { + for (const auto& point : bands[band]) { + const int pointBucket = std::min( + ageBuckets - 1, + static_cast(point.intensity * + static_cast(ageBuckets))); + if (pointBucket != bucket) { + continue; + } + surface.DrawPoint( + point.x, point.y, true, colors[band][bucket]); + } + } + } + }) | flex; + + auto panel = window( + panelTitle( + PanelId::Vectorscope, + focused, + vectorscopeModeName(mode)), + std::move(plot)); + return stylePanel(std::move(panel), focused) | + size(WIDTH, EQUAL, std::max(1, width)) | + size(HEIGHT, EQUAL, std::max(1, height)); +} + ftxui::Element renderLevelsPanel(const DisplayFrame& frame, int width, int height, @@ -219,17 +529,28 @@ const PanelRect* findPanelRect(const DashboardLayout& layout, PanelId panel) { ftxui::Element renderLayoutNode(const LayoutNode& node, const DashboardLayout& layout, const DisplayFrame& frame, - PanelId focusedPanel) { + const InterfaceState& state) { using namespace ftxui; if (node.isLeaf()) { const auto* rect = findPanelRect(layout, *node.panel); if (rect == nullptr) { return emptyElement(); } - const bool focused = *node.panel == focusedPanel; + const bool focused = *node.panel == state.focusedPanel; switch (*node.panel) { case PanelId::Spectrum: - return renderSpectrumPanel(frame, rect->width, rect->height, focused); + return renderSpectrumPanel( + frame, rect->width, rect->height, focused, state.settings); + case PanelId::Oscilloscope: + return renderOscilloscopePanel( + frame, rect->width, rect->height, focused, state.settings); + case PanelId::Vectorscope: + return renderVectorscopePanel( + frame, + rect->width, + rect->height, + focused, + state.settings); case PanelId::Levels: return renderLevelsPanel(frame, rect->width, rect->height, focused); } @@ -238,7 +559,7 @@ ftxui::Element renderLayoutNode(const LayoutNode& node, Elements children; children.reserve(node.children.size()); for (const auto& child : node.children) { - children.push_back(renderLayoutNode(child, layout, frame, focusedPanel)); + children.push_back(renderLayoutNode(child, layout, frame, state)); } return node.axis == SplitAxis::Columns ? hbox(std::move(children)) @@ -248,8 +569,8 @@ ftxui::Element renderLayoutNode(const LayoutNode& node, ftxui::Element renderHeader(const DashboardLayout& layout, const InterfaceState& state) { using namespace ftxui; - std::string layoutName = layoutPresetName(state.layoutPreset); - if (state.layoutPreset == LayoutPreset::Automatic) { + std::string layoutName = layoutPresetName(state.settings.layoutPreset); + if (state.settings.layoutPreset == LayoutPreset::Automatic) { layoutName += "→" + layoutPresetName(layout.resolvedPreset); } return hbox({ @@ -270,11 +591,12 @@ ftxui::Element renderFooter(const DisplayFrame& frame, const bool minimal = width < 64; const std::string enterAction = state.expandedPanel ? "restore" : "expand"; const std::string controls = minimal - ? "Tab • Enter • l • q" + ? "Tab • Enter • s • q" : compact - ? "Tab focus • Enter " + enterAction + " • l layout • q quit" - : "Tab focus • Enter " + enterAction + " • l layout • r reset • q quit"; - auto status = text(makeCaptureStatus(frame, compact)) | dim; + ? "Tab focus • Enter " + enterAction + " • s settings • q quit" + : "Tab focus • Enter " + enterAction + + " • s settings • v mode • l layout • r reset • q quit"; + auto status = text(makeCaptureStatus(frame, state.settings, compact)) | dim; if (frame.captureOverrun) { status = status | color(Color::RedLight); } @@ -285,13 +607,109 @@ ftxui::Element renderFooter(const DisplayFrame& frame, }); } +ftxui::Element settingsRow(const std::string& label, + const std::string& value, + bool selected) { + using namespace ftxui; + auto row = hbox({ + text(selected ? " › " : " "), + text(label), + filler(), + text(value), + text(" "), + }); + if (selected) { + row = row | color(Color::CyanLight) | bold | + bgcolor(Color::RGB(24, 42, 46)); + } else { + row = row | color(Color::GrayLight); + } + return row | size(HEIGHT, EQUAL, 1); +} + +ftxui::Element renderSettings(const InterfaceState& state, + int width, + int height) { + using namespace ftxui; + const int contentWidth = std::max(1, width - 2); + const int contentHeight = std::max(1, height - 2); + const std::string breadcrumb = state.settingsPage == SettingsPage::Home + ? " PRISM / SETTINGS" + : " PRISM / SETTINGS › " + std::string(settingsPageName(state.settingsPage)); + Elements rows; + std::string selectedDescription; + const size_t maximumVisibleRows = static_cast( + std::max(1, contentHeight - 8)); + if (state.settingsPage == SettingsPage::Home) { + const auto pages = settingsPages(); + const size_t selectedIndex = std::min( + state.settingsHomeSelection, + pages.empty() ? size_t{0} : pages.size() - 1); + const size_t firstVisible = selectedIndex >= maximumVisibleRows + ? selectedIndex - maximumVisibleRows + 1 + : 0; + const size_t lastVisible = std::min( + pages.size(), firstVisible + maximumVisibleRows); + for (size_t index = firstVisible; index < lastVisible; ++index) { + const bool selected = index == state.settingsHomeSelection; + rows.push_back(settingsRow( + std::to_string(index + 1) + " " + settingsPageName(pages[index]), + {}, + selected)); + if (selected) selectedDescription = settingsPageDescription(pages[index]); + } + } else { + const auto& settings = settingsForPage(state.settingsPage); + const size_t selectedIndex = std::min( + settingsSelection(state), + settings.empty() ? size_t{0} : settings.size() - 1); + const size_t firstVisible = selectedIndex >= maximumVisibleRows + ? selectedIndex - maximumVisibleRows + 1 + : 0; + const size_t lastVisible = std::min( + settings.size(), firstVisible + maximumVisibleRows); + for (size_t index = firstVisible; index < lastVisible; ++index) { + const bool selected = index == selectedIndex; + rows.push_back(settingsRow( + settings[index].name, + settingValue(state.settings, settings[index].id), + selected)); + if (selected) selectedDescription = settings[index].description; + } + } + + const std::string controls = state.settingsPage == SettingsPage::Home + ? "↑↓ select • Enter open • s/Esc dashboard" + : contentWidth < 76 + ? "↑↓ select • ←→ adjust • Enter • Esc back" + : "↑↓ select • ←→ adjust • Enter toggle • Backspace default • Esc back"; + auto content = vbox({ + text(breadcrumb) | color(Color::CyanLight) | bold, + separator(), + text(settingsPageDescription(state.settingsPage)) | dim, + separatorEmpty(), + vbox(std::move(rows)), + filler(), + text(selectedDescription) | color(Color::GrayLight), + state.settingsStatus.empty() + ? emptyElement() + : text(state.settingsStatus) | color(Color::RedLight), + separator(), + text(controls) | dim, + }) | size(WIDTH, EQUAL, contentWidth) | + size(HEIGHT, EQUAL, contentHeight); + return std::move(content) | borderRounded | + size(WIDTH, EQUAL, width) | + size(HEIGHT, EQUAL, height); +} + ftxui::Element renderFrame(const DisplayFrame& frame, int width, int height, const InterfaceState& state) { using namespace ftxui; const auto layout = buildDashboardLayout( - width, height, state.layoutPreset, state.expandedPanel); + width, height, state.settings.layoutPreset, state.expandedPanel); if (layout.terminalTooSmall) { return vbox({ filler(), @@ -302,11 +720,22 @@ ftxui::Element renderFrame(const DisplayFrame& frame, }); } - return vbox({ + auto dashboard = vbox({ renderHeader(layout, state) | size(HEIGHT, EQUAL, 1), - renderLayoutNode(layout.root, layout, frame, state.focusedPanel), + renderLayoutNode(layout.root, layout, frame, state), renderFooter(frame, state, width) | size(HEIGHT, EQUAL, 1), }); + if (!state.settingsOpen) { + return dashboard; + } + + const int settingsWidth = std::min(84, std::max(40, width - 4)); + const int settingsHeight = std::min(16, std::max(10, height - 2)); + return dbox({ + std::move(dashboard) | dim, + renderSettings(state, settingsWidth, settingsHeight) | + borderEmpty | clear_under | center, + }); } } // namespace @@ -327,7 +756,11 @@ int runInteractive(std::unique_ptr capture, ScreenInteractive screen = ScreenInteractive::Fullscreen(); SnapshotStore frameStore; + SnapshotStore settingsStore; InterfaceState interfaceState; + const std::filesystem::path settingsPath = defaultSettingsPath(); + interfaceState.settings = loadSettings(settingsPath); + settingsStore.publish(interfaceState.settings); DisplayFrame initial; initial.magnitudes.assign(kDefaultFftSize / 2, -100.0f); initial.sampleRate = started.sampleRate; @@ -337,12 +770,17 @@ int runInteractive(std::unique_ptr capture, std::atomic running{true}; std::atomic resetRequested{false}; + std::atomic redrawQueued{false}; std::exception_ptr workerError; auto exitLoop = screen.ExitLoopClosure(); std::thread worker([&]() { try { AnalysisPipeline pipeline(static_cast(started.sampleRate)); + TuiSettings appliedSettings = settingsStore.read(); + pipeline.setInputTrimDb(appliedSettings.inputTrimDb); + pipeline.setSpectrumTilt(appliedSettings.spectrumTiltDbPerOctave); + pipeline.setOscilloscopePitchLock(appliedSettings.oscilloscopePitchLock); bool captureOverrun = false; auto nextFrameAt = std::chrono::steady_clock::now(); @@ -357,6 +795,21 @@ int runInteractive(std::unique_ptr capture, captureOverrun = false; } + const TuiSettings requestedSettings = settingsStore.read(); + if (requestedSettings != appliedSettings) { + const bool refreshChanged = + requestedSettings.refreshRate != appliedSettings.refreshRate; + pipeline.setInputTrimDb(requestedSettings.inputTrimDb); + pipeline.setSpectrumTilt( + requestedSettings.spectrumTiltDbPerOctave); + pipeline.setOscilloscopePitchLock( + requestedSettings.oscilloscopePitchLock); + appliedSettings = requestedSettings; + if (refreshChanged) { + nextFrameAt = std::chrono::steady_clock::now(); + } + } + drainCapture(*capture, pipeline, captureOverrun); const auto now = std::chrono::steady_clock::now(); @@ -364,15 +817,21 @@ int runInteractive(std::unique_ptr capture, DisplayFrame next; auto analyzed = pipeline.snapshot(); next.magnitudes = std::move(analyzed.magnitudes); + next.spectrumPeak = std::move(analyzed.spectrumPeak); next.vu = analyzed.vu; next.lufs = analyzed.lufs; + next.oscilloscope = std::move(analyzed.oscilloscope); + next.vectorscope = std::move(analyzed.vectorscope); next.sampleRate = started.sampleRate; next.backend = capture->backendName(); next.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel; next.captureOverrun = captureOverrun; frameStore.publish(std::move(next)); - screen.PostEvent(Event::Custom); - nextFrameAt = now + kDisplayFrameInterval; + if (running.load() && !redrawQueued.exchange(true)) { + screen.PostEvent(Event::Custom); + } + nextFrameAt = now + displayFrameInterval( + appliedSettings.refreshRate); } std::this_thread::sleep_for(kCapturePollInterval); } @@ -388,8 +847,126 @@ int runInteractive(std::unique_ptr capture, return renderFrame( frameStore.read(), screen.dimx(), screen.dimy(), interfaceState); }); + const auto persistSettings = [&]() { + interfaceState.settings = normalizeSettings(interfaceState.settings); + settingsStore.publish(interfaceState.settings); + std::string error; + if (!saveSettings(interfaceState.settings, settingsPath, &error)) { + interfaceState.settingsStatus = "Settings were applied but could not be saved: " + error; + } else { + interfaceState.settingsStatus.clear(); + } + }; + const auto closeSettings = [&]() { + interfaceState.settingsOpen = false; + interfaceState.settingsPage = SettingsPage::Home; + const auto dashboard = buildDashboardLayout( + screen.dimx(), + screen.dimy(), + interfaceState.settings.layoutPreset, + interfaceState.expandedPanel); + if (!interfaceState.expandedPanel && + !layoutContainsPanel(dashboard, interfaceState.focusedPanel)) { + const auto visible = visiblePanelOrder(dashboard); + if (!visible.empty()) interfaceState.focusedPanel = visible.front(); + } + }; auto component = CatchEvent(renderer, [&](Event event) { - if (event == Event::Character('q') || event == Event::Escape || event == Event::CtrlC) { + if (event == Event::Custom) { + redrawQueued.store(false); + return false; + } + if (event == Event::Character('q') || event == Event::CtrlC) { + running.store(false); + exitLoop(); + return true; + } + if (event == Event::Character('s')) { + if (interfaceState.settingsOpen) { + closeSettings(); + } else { + interfaceState.settingsOpen = true; + interfaceState.settingsPage = SettingsPage::Home; + interfaceState.settingsHomeSelection = 0; + } + return true; + } + if (interfaceState.settingsOpen) { + if (event == Event::Escape) { + if (interfaceState.settingsPage == SettingsPage::Home) { + closeSettings(); + } else { + const auto pages = settingsPages(); + const auto found = std::find( + pages.begin(), pages.end(), interfaceState.settingsPage); + interfaceState.settingsHomeSelection = found == pages.end() + ? 0 + : static_cast(std::distance(pages.begin(), found)); + interfaceState.settingsPage = SettingsPage::Home; + } + return true; + } + + if (interfaceState.settingsPage == SettingsPage::Home) { + const auto pages = settingsPages(); + if (event == Event::ArrowUp || event == Event::ArrowDown) { + const int direction = event == Event::ArrowDown ? 1 : -1; + const int count = static_cast(pages.size()); + interfaceState.settingsHomeSelection = static_cast( + (static_cast(interfaceState.settingsHomeSelection) + + direction + count) % count); + return true; + } + if (event == Event::Return && !pages.empty()) { + interfaceState.settingsPage = pages[std::min( + interfaceState.settingsHomeSelection, pages.size() - 1)]; + return true; + } + for (size_t index = 0; index < pages.size(); ++index) { + if (event == Event::Character( + static_cast('1' + index))) { + interfaceState.settingsPage = pages[index]; + interfaceState.settingsHomeSelection = index; + return true; + } + } + return true; + } + + const auto& pageSettings = settingsForPage(interfaceState.settingsPage); + size_t& selected = settingsSelection(interfaceState); + if (!pageSettings.empty()) { + selected = std::min(selected, pageSettings.size() - 1); + } + if ((event == Event::ArrowUp || event == Event::ArrowDown) && + !pageSettings.empty()) { + const int direction = event == Event::ArrowDown ? 1 : -1; + const int count = static_cast(pageSettings.size()); + selected = static_cast( + (static_cast(selected) + direction + count) % count); + return true; + } + if (!pageSettings.empty() && + (event == Event::ArrowLeft || event == Event::ArrowRight || + event == Event::Return)) { + const int direction = event == Event::ArrowLeft ? -1 : 1; + if (adjustSetting( + interfaceState.settings, + pageSettings[selected].id, + direction)) { + persistSettings(); + } + return true; + } + if (!pageSettings.empty() && event == Event::Backspace) { + if (resetSetting(interfaceState.settings, pageSettings[selected].id)) { + persistSettings(); + } + return true; + } + return true; + } + if (event == Event::Escape) { running.store(false); exitLoop(); return true; @@ -398,9 +975,24 @@ int runInteractive(std::unique_ptr capture, resetRequested.store(true); return true; } + if (event == Event::Character('v')) { + adjustSetting( + interfaceState.settings, SettingId::VectorscopeMode, 1); + persistSettings(); + return true; + } if (event == Event::Tab || event == Event::TabReverse) { + const auto navigationLayout = buildDashboardLayout( + screen.dimx(), + screen.dimy(), + interfaceState.settings.layoutPreset, + interfaceState.expandedPanel); + const auto navigationPanels = interfaceState.expandedPanel + ? panelOrder() + : visiblePanelOrder(navigationLayout); interfaceState.focusedPanel = nextPanel( interfaceState.focusedPanel, + navigationPanels, event == Event::TabReverse); if (interfaceState.expandedPanel) { interfaceState.expandedPanel = interfaceState.focusedPanel; @@ -416,16 +1008,34 @@ int runInteractive(std::unique_ptr capture, return true; } if (event == Event::Character('l')) { - interfaceState.layoutPreset = nextLayoutPreset(interfaceState.layoutPreset); + adjustSetting(interfaceState.settings, SettingId::Layout, 1); + persistSettings(); interfaceState.expandedPanel.reset(); + const auto nextLayout = buildDashboardLayout( + screen.dimx(), screen.dimy(), interfaceState.settings.layoutPreset); + if (!layoutContainsPanel(nextLayout, interfaceState.focusedPanel)) { + const auto visible = visiblePanelOrder(nextLayout); + if (!visible.empty()) { + interfaceState.focusedPanel = visible.front(); + } + } return true; } - if (event == Event::Character('1') || event == Event::Character('2')) { - interfaceState.focusedPanel = event == Event::Character('1') - ? PanelId::Spectrum - : PanelId::Levels; + std::optional selectedPanel; + if (event == Event::Character('1')) selectedPanel = PanelId::Spectrum; + if (event == Event::Character('2')) selectedPanel = PanelId::Oscilloscope; + if (event == Event::Character('3')) selectedPanel = PanelId::Vectorscope; + if (event == Event::Character('4')) selectedPanel = PanelId::Levels; + if (selectedPanel) { + interfaceState.focusedPanel = *selectedPanel; if (interfaceState.expandedPanel) { interfaceState.expandedPanel = interfaceState.focusedPanel; + } else { + const auto currentLayout = buildDashboardLayout( + screen.dimx(), screen.dimy(), interfaceState.settings.layoutPreset); + if (!layoutContainsPanel(currentLayout, interfaceState.focusedPanel)) { + interfaceState.expandedPanel = interfaceState.focusedPanel; + } } return true; } diff --git a/tui/src/tui_settings.cpp b/tui/src/tui_settings.cpp new file mode 100644 index 0000000..2867c32 --- /dev/null +++ b/tui/src/tui_settings.cpp @@ -0,0 +1,409 @@ +#include "tui_settings.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace Prism::Tui { +namespace { + +const std::vector kGeneralSettings = { + {SettingId::InputTrim, "Input trim", "Applies gain before every analyzer."}, + {SettingId::RefreshRate, "Refresh rate", "Controls how often the terminal display is published."}, + {SettingId::Layout, "Dashboard layout", "Chooses automatic, stacked, or column panes."}, +}; + +const std::vector kSpectrumSettings = { + {SettingId::SpectrumPeakReadout, "Peak readout", "Shows the strongest stable spectral peak in the panel title."}, + {SettingId::SpectrumTilt, "Display tilt", "Offsets the spectrum by decibels per octave around 1 kHz."}, +}; + +const std::vector kOscilloscopeSettings = { + {SettingId::OscilloscopePitchLock, "Pitch lock", "Stabilizes the waveform around its detected fundamental."}, + {SettingId::OscilloscopeFrequencyReadout, "Frequency readout", "Shows the live detected fundamental while pitch lock is enabled."}, + {SettingId::OscilloscopeTraceWeight, "Trace weight", "Changes the thickness of the oscilloscope trace."}, +}; + +const std::vector kVectorscopeSettings = { + {SettingId::VectorscopeMode, "Display mode", "Changes the stereo projection used by the vectorscope."}, + {SettingId::VectorscopeGuides, "Guides", "Shows the mode-specific reference contours and axes."}, + {SettingId::VectorscopeDetail, "Point detail", "Balances point density against terminal rendering cost."}, +}; + +float snap(float value, float step) { + return std::round(value / step) * step; +} + +std::string boolValue(bool enabled) { + return enabled ? "● On" : "○ Off"; +} + +std::string trimFloat(float value, int precision) { + std::ostringstream output; + output << std::fixed << std::setprecision(precision) << value; + return output.str(); +} + +std::string serializeLayout(LayoutPreset layout) { + return layoutPresetName(layout); +} + +std::string serializeVectorMode(VectorscopeMode mode) { + switch (mode) { + case VectorscopeMode::Lissajous: return "lissajous"; + case VectorscopeMode::PolarUnipolar: return "polar_unipolar"; + case VectorscopeMode::PolarBipolar: return "polar_bipolar"; + case VectorscopeMode::LinearUnipolar: return "linear_unipolar"; + case VectorscopeMode::LinearBipolar: return "linear_bipolar"; + } + return "lissajous"; +} + +std::string serializeVectorDetail(VectorscopeDetail detail) { + switch (detail) { + case VectorscopeDetail::Balanced: return "balanced"; + case VectorscopeDetail::Detailed: return "detailed"; + case VectorscopeDetail::Maximum: return "maximum"; + } + return "detailed"; +} + +bool parseBool(const std::string& value, bool fallback) { + if (value == "true" || value == "1" || value == "on") return true; + if (value == "false" || value == "0" || value == "off") return false; + return fallback; +} + +float parseFloat(const std::string& value, float fallback) { + try { + size_t consumed = 0; + const float parsed = std::stof(value, &consumed); + return consumed == value.size() && std::isfinite(parsed) ? parsed : fallback; + } catch (...) { + return fallback; + } +} + +int parseInt(const std::string& value, int fallback) { + try { + size_t consumed = 0; + const int parsed = std::stoi(value, &consumed); + return consumed == value.size() ? parsed : fallback; + } catch (...) { + return fallback; + } +} + +LayoutPreset parseLayout(const std::string& value, LayoutPreset fallback) { + if (value == "auto") return LayoutPreset::Automatic; + if (value == "stacked") return LayoutPreset::Stacked; + if (value == "columns") return LayoutPreset::Columns; + return fallback; +} + +VectorscopeMode parseVectorMode(const std::string& value, VectorscopeMode fallback) { + if (value == "lissajous") return VectorscopeMode::Lissajous; + if (value == "polar_unipolar") return VectorscopeMode::PolarUnipolar; + if (value == "polar_bipolar") return VectorscopeMode::PolarBipolar; + if (value == "linear_unipolar") return VectorscopeMode::LinearUnipolar; + if (value == "linear_bipolar") return VectorscopeMode::LinearBipolar; + return fallback; +} + +VectorscopeDetail parseVectorDetail(const std::string& value, + VectorscopeDetail fallback) { + if (value == "balanced") return VectorscopeDetail::Balanced; + if (value == "detailed") return VectorscopeDetail::Detailed; + if (value == "maximum") return VectorscopeDetail::Maximum; + return fallback; +} + +const char* environmentValue(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] != '\0' ? value : nullptr; +} + +} // namespace + +TuiSettings normalizeSettings(TuiSettings settings) { + settings.inputTrimDb = std::clamp(snap(settings.inputTrimDb, 0.5f), -12.0f, 12.0f); + settings.refreshRate = settings.refreshRate <= 30 ? 30 : 60; + settings.spectrumTiltDbPerOctave = std::clamp( + snap(settings.spectrumTiltDbPerOctave, 0.1f), -2.0f, 8.0f); + settings.oscilloscopeTraceWeight = std::clamp(settings.oscilloscopeTraceWeight, 1, 3); + if (!settings.oscilloscopePitchLock) { + settings.oscilloscopeFrequencyReadout = false; + } + return settings; +} + +bool operator==(const TuiSettings& left, const TuiSettings& right) { + return left.inputTrimDb == right.inputTrimDb && + left.refreshRate == right.refreshRate && + left.layoutPreset == right.layoutPreset && + left.spectrumPeakReadout == right.spectrumPeakReadout && + left.spectrumTiltDbPerOctave == right.spectrumTiltDbPerOctave && + left.oscilloscopePitchLock == right.oscilloscopePitchLock && + left.oscilloscopeFrequencyReadout == right.oscilloscopeFrequencyReadout && + left.oscilloscopeTraceWeight == right.oscilloscopeTraceWeight && + left.vectorscopeMode == right.vectorscopeMode && + left.vectorscopeGuides == right.vectorscopeGuides && + left.vectorscopeDetail == right.vectorscopeDetail; +} + +bool operator!=(const TuiSettings& left, const TuiSettings& right) { + return !(left == right); +} + +std::vector settingsPages() { + return { + SettingsPage::General, + SettingsPage::Spectrum, + SettingsPage::Oscilloscope, + SettingsPage::Vectorscope, + }; +} + +const char* settingsPageName(SettingsPage page) { + switch (page) { + case SettingsPage::Home: return "Settings"; + case SettingsPage::General: return "General"; + case SettingsPage::Spectrum: return "Spectrum"; + case SettingsPage::Oscilloscope: return "Oscilloscope"; + case SettingsPage::Vectorscope: return "Vectorscope"; + } + return "Settings"; +} + +const char* settingsPageDescription(SettingsPage page) { + switch (page) { + case SettingsPage::Home: return "Choose a section."; + case SettingsPage::General: return "Audio input and dashboard behavior."; + case SettingsPage::Spectrum: return "Frequency analysis and readouts."; + case SettingsPage::Oscilloscope: return "Waveform stabilization and presentation."; + case SettingsPage::Vectorscope: return "Stereo projection and point rendering."; + } + return {}; +} + +const std::vector& settingsForPage(SettingsPage page) { + switch (page) { + case SettingsPage::General: return kGeneralSettings; + case SettingsPage::Spectrum: return kSpectrumSettings; + case SettingsPage::Oscilloscope: return kOscilloscopeSettings; + case SettingsPage::Vectorscope: return kVectorscopeSettings; + case SettingsPage::Home: break; + } + static const std::vector empty; + return empty; +} + +std::string settingValue(const TuiSettings& settings, SettingId setting) { + switch (setting) { + case SettingId::InputTrim: + return (settings.inputTrimDb > 0.0f ? "+" : "") + + trimFloat(settings.inputTrimDb, 1) + " dB"; + case SettingId::RefreshRate: + return std::to_string(settings.refreshRate) + " FPS"; + case SettingId::Layout: + return layoutPresetName(settings.layoutPreset); + case SettingId::SpectrumPeakReadout: + return boolValue(settings.spectrumPeakReadout); + case SettingId::SpectrumTilt: + return trimFloat(settings.spectrumTiltDbPerOctave, 1) + " dB/oct"; + case SettingId::OscilloscopePitchLock: + return boolValue(settings.oscilloscopePitchLock); + case SettingId::OscilloscopeFrequencyReadout: + return boolValue(settings.oscilloscopeFrequencyReadout); + case SettingId::OscilloscopeTraceWeight: + return std::to_string(settings.oscilloscopeTraceWeight); + case SettingId::VectorscopeMode: + return vectorscopeModeName(settings.vectorscopeMode); + case SettingId::VectorscopeGuides: + return boolValue(settings.vectorscopeGuides); + case SettingId::VectorscopeDetail: + switch (settings.vectorscopeDetail) { + case VectorscopeDetail::Balanced: return "Balanced"; + case VectorscopeDetail::Detailed: return "Detailed"; + case VectorscopeDetail::Maximum: return "Maximum"; + } + } + return {}; +} + +bool settingIsBoolean(SettingId setting) { + return setting == SettingId::SpectrumPeakReadout || + setting == SettingId::OscilloscopePitchLock || + setting == SettingId::OscilloscopeFrequencyReadout || + setting == SettingId::VectorscopeGuides; +} + +bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) { + if (direction == 0) return false; + const TuiSettings before = settings; + switch (setting) { + case SettingId::InputTrim: + settings.inputTrimDb += direction > 0 ? 0.5f : -0.5f; + break; + case SettingId::RefreshRate: + settings.refreshRate = settings.refreshRate == 60 ? 30 : 60; + break; + case SettingId::Layout: + if (direction > 0) { + settings.layoutPreset = nextLayoutPreset(settings.layoutPreset); + } else { + settings.layoutPreset = settings.layoutPreset == LayoutPreset::Automatic + ? LayoutPreset::Columns + : settings.layoutPreset == LayoutPreset::Columns + ? LayoutPreset::Stacked + : LayoutPreset::Automatic; + } + break; + case SettingId::SpectrumPeakReadout: + settings.spectrumPeakReadout = !settings.spectrumPeakReadout; + break; + case SettingId::SpectrumTilt: + settings.spectrumTiltDbPerOctave += direction > 0 ? 0.1f : -0.1f; + break; + case SettingId::OscilloscopePitchLock: + settings.oscilloscopePitchLock = !settings.oscilloscopePitchLock; + if (!settings.oscilloscopePitchLock) { + settings.oscilloscopeFrequencyReadout = false; + } + break; + case SettingId::OscilloscopeFrequencyReadout: + if (!settings.oscilloscopePitchLock) { + return false; + } + settings.oscilloscopeFrequencyReadout = !settings.oscilloscopeFrequencyReadout; + break; + case SettingId::OscilloscopeTraceWeight: + settings.oscilloscopeTraceWeight += direction > 0 ? 1 : -1; + break; + case SettingId::VectorscopeMode: + if (direction > 0) { + settings.vectorscopeMode = nextVectorscopeMode(settings.vectorscopeMode); + } else { + for (int index = 0; index < 4; ++index) { + settings.vectorscopeMode = nextVectorscopeMode(settings.vectorscopeMode); + } + } + break; + case SettingId::VectorscopeGuides: + settings.vectorscopeGuides = !settings.vectorscopeGuides; + break; + case SettingId::VectorscopeDetail: { + int value = static_cast(settings.vectorscopeDetail); + value = std::clamp(value + (direction > 0 ? 1 : -1), 0, 2); + settings.vectorscopeDetail = static_cast(value); + break; + } + } + settings = normalizeSettings(settings); + return settings != before; +} + +bool resetSetting(TuiSettings& settings, SettingId setting) { + const TuiSettings defaults; + const TuiSettings before = settings; + switch (setting) { + case SettingId::InputTrim: settings.inputTrimDb = defaults.inputTrimDb; break; + case SettingId::RefreshRate: settings.refreshRate = defaults.refreshRate; break; + case SettingId::Layout: settings.layoutPreset = defaults.layoutPreset; break; + case SettingId::SpectrumPeakReadout: settings.spectrumPeakReadout = defaults.spectrumPeakReadout; break; + case SettingId::SpectrumTilt: settings.spectrumTiltDbPerOctave = defaults.spectrumTiltDbPerOctave; break; + case SettingId::OscilloscopePitchLock: settings.oscilloscopePitchLock = defaults.oscilloscopePitchLock; break; + case SettingId::OscilloscopeFrequencyReadout: settings.oscilloscopeFrequencyReadout = defaults.oscilloscopeFrequencyReadout; break; + case SettingId::OscilloscopeTraceWeight: settings.oscilloscopeTraceWeight = defaults.oscilloscopeTraceWeight; break; + case SettingId::VectorscopeMode: settings.vectorscopeMode = defaults.vectorscopeMode; break; + case SettingId::VectorscopeGuides: settings.vectorscopeGuides = defaults.vectorscopeGuides; break; + case SettingId::VectorscopeDetail: settings.vectorscopeDetail = defaults.vectorscopeDetail; break; + } + return settings != before; +} + +std::filesystem::path defaultSettingsPath() { +#if defined(_WIN32) + if (const char* appData = environmentValue("APPDATA")) { + return std::filesystem::path(appData) / "Prism" / "tui.conf"; + } +#elif defined(__APPLE__) + if (const char* home = environmentValue("HOME")) { + return std::filesystem::path(home) / + "Library" / "Application Support" / "Prism" / "tui.conf"; + } +#else + if (const char* xdgConfig = environmentValue("XDG_CONFIG_HOME")) { + return std::filesystem::path(xdgConfig) / "prism" / "tui.conf"; + } + if (const char* home = environmentValue("HOME")) { + return std::filesystem::path(home) / ".config" / "prism" / "tui.conf"; + } +#endif + return std::filesystem::path("prism-tui.conf"); +} + +TuiSettings loadSettings(const std::filesystem::path& path) { + TuiSettings settings; + std::ifstream input(path); + std::string line; + while (std::getline(input, line)) { + const size_t separator = line.find('='); + if (separator == std::string::npos) continue; + const std::string key = line.substr(0, separator); + const std::string value = line.substr(separator + 1); + if (key == "input_trim_db") settings.inputTrimDb = parseFloat(value, settings.inputTrimDb); + else if (key == "refresh_rate") settings.refreshRate = parseInt(value, settings.refreshRate); + else if (key == "layout") settings.layoutPreset = parseLayout(value, settings.layoutPreset); + else if (key == "spectrum_peak") settings.spectrumPeakReadout = parseBool(value, settings.spectrumPeakReadout); + else if (key == "spectrum_tilt") settings.spectrumTiltDbPerOctave = parseFloat(value, settings.spectrumTiltDbPerOctave); + else if (key == "osc_pitch_lock") settings.oscilloscopePitchLock = parseBool(value, settings.oscilloscopePitchLock); + else if (key == "osc_frequency") settings.oscilloscopeFrequencyReadout = parseBool(value, settings.oscilloscopeFrequencyReadout); + else if (key == "osc_trace_weight") settings.oscilloscopeTraceWeight = parseInt(value, settings.oscilloscopeTraceWeight); + else if (key == "vector_mode") settings.vectorscopeMode = parseVectorMode(value, settings.vectorscopeMode); + else if (key == "vector_guides") settings.vectorscopeGuides = parseBool(value, settings.vectorscopeGuides); + else if (key == "vector_detail") settings.vectorscopeDetail = parseVectorDetail(value, settings.vectorscopeDetail); + } + return normalizeSettings(settings); +} + +bool saveSettings(const TuiSettings& rawSettings, + const std::filesystem::path& path, + std::string* error) { + const TuiSettings settings = normalizeSettings(rawSettings); + std::error_code filesystemError; + if (!path.parent_path().empty()) { + std::filesystem::create_directories(path.parent_path(), filesystemError); + if (filesystemError) { + if (error) *error = filesystemError.message(); + return false; + } + } + std::ofstream output(path, std::ios::trunc); + if (!output) { + if (error) *error = "could not open settings file"; + return false; + } + output << "input_trim_db=" << settings.inputTrimDb << '\n' + << "refresh_rate=" << settings.refreshRate << '\n' + << "layout=" << serializeLayout(settings.layoutPreset) << '\n' + << "spectrum_peak=" << (settings.spectrumPeakReadout ? "true" : "false") << '\n' + << "spectrum_tilt=" << settings.spectrumTiltDbPerOctave << '\n' + << "osc_pitch_lock=" << (settings.oscilloscopePitchLock ? "true" : "false") << '\n' + << "osc_frequency=" << (settings.oscilloscopeFrequencyReadout ? "true" : "false") << '\n' + << "osc_trace_weight=" << settings.oscilloscopeTraceWeight << '\n' + << "vector_mode=" << serializeVectorMode(settings.vectorscopeMode) << '\n' + << "vector_guides=" << (settings.vectorscopeGuides ? "true" : "false") << '\n' + << "vector_detail=" << serializeVectorDetail(settings.vectorscopeDetail) << '\n'; + if (!output) { + if (error) *error = "could not write settings file"; + return false; + } + return true; +} + +} // namespace Prism::Tui diff --git a/tui/src/tui_settings.h b/tui/src/tui_settings.h new file mode 100644 index 0000000..5983a37 --- /dev/null +++ b/tui/src/tui_settings.h @@ -0,0 +1,80 @@ +#pragma once + +#include "dashboard_layout.h" +#include "scope_plot_model.h" + +#include +#include +#include + +namespace Prism::Tui { + +enum class SettingsPage { + Home, + General, + Spectrum, + Oscilloscope, + Vectorscope, +}; + +enum class SettingId { + InputTrim, + RefreshRate, + Layout, + SpectrumPeakReadout, + SpectrumTilt, + OscilloscopePitchLock, + OscilloscopeFrequencyReadout, + OscilloscopeTraceWeight, + VectorscopeMode, + VectorscopeGuides, + VectorscopeDetail, +}; + +enum class VectorscopeDetail { + Balanced, + Detailed, + Maximum, +}; + +struct TuiSettings { + float inputTrimDb = 0.0f; + int refreshRate = 60; + LayoutPreset layoutPreset = LayoutPreset::Automatic; + bool spectrumPeakReadout = true; + float spectrumTiltDbPerOctave = 2.0f; + bool oscilloscopePitchLock = true; + bool oscilloscopeFrequencyReadout = true; + int oscilloscopeTraceWeight = 2; + VectorscopeMode vectorscopeMode = VectorscopeMode::Lissajous; + bool vectorscopeGuides = true; + VectorscopeDetail vectorscopeDetail = VectorscopeDetail::Detailed; +}; + +struct SettingDescriptor { + SettingId id; + const char* name; + const char* description; +}; + +TuiSettings normalizeSettings(TuiSettings settings); +bool operator==(const TuiSettings& left, const TuiSettings& right); +bool operator!=(const TuiSettings& left, const TuiSettings& right); + +std::vector settingsPages(); +const char* settingsPageName(SettingsPage page); +const char* settingsPageDescription(SettingsPage page); +const std::vector& settingsForPage(SettingsPage page); + +std::string settingValue(const TuiSettings& settings, SettingId setting); +bool settingIsBoolean(SettingId setting); +bool adjustSetting(TuiSettings& settings, SettingId setting, int direction); +bool resetSetting(TuiSettings& settings, SettingId setting); + +std::filesystem::path defaultSettingsPath(); +TuiSettings loadSettings(const std::filesystem::path& path); +bool saveSettings(const TuiSettings& settings, + const std::filesystem::path& path, + std::string* error = nullptr); + +} // namespace Prism::Tui diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index bf0d02b..cb731fe 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -2,13 +2,17 @@ #include "cli.h" #include "dashboard_layout.h" #include "display_model.h" +#include "scope_plot_model.h" #include "snapshot_store.h" +#include "spectrum_peak_model.h" #include "system_audio_capture.h" +#include "tui_settings.h" #include #include #include #include +#include #include #include #include @@ -107,6 +111,8 @@ void testCli() { "exclusive commands should not combine"); require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos, "help should describe dashboard keyboard controls"); + require(Prism::Tui::usageText().find("Cycle vectorscope") != std::string::npos, + "help should describe vectorscope mode controls"); } void testProjectionAndLayout() { @@ -149,13 +155,23 @@ void testProjectionAndLayout() { require(!wide.terminalTooSmall && wide.resolvedPreset == Prism::Tui::LayoutPreset::Columns, "wide, tall terminals should use the columns dashboard"); - require(wide.panels.size() == 2 && - wide.panels[0].width + wide.panels[1].width == 100 && - wide.panels[0].width > wide.panels[1].width, - "column panels should fill the width and favor the spectrum"); + require(wide.panels.size() == 4 && + wide.panels[0].panel == Prism::Tui::PanelId::Spectrum && + wide.panels[1].panel == Prism::Tui::PanelId::Oscilloscope && + wide.panels[2].panel == Prism::Tui::PanelId::Vectorscope && + wide.panels[3].panel == Prism::Tui::PanelId::Levels, + "the dashboard should contain all four scope panels"); + require(wide.panels[0].width == wide.panels[1].width && + wide.panels[2].width == wide.panels[3].width && + wide.panels[0].width + wide.panels[2].width == 100 && + wide.panels[0].width > wide.panels[2].width, + "dashboard columns should fill the width and favor visual plots"); + require(wide.panels[0].height + wide.panels[1].height == 28 && + wide.panels[2].height + wide.panels[3].height == 28, + "both dashboard columns should fill the available height"); const auto stacked = Prism::Tui::buildDashboardLayout( - 80, 20, Prism::Tui::LayoutPreset::Automatic); + 60, 20, Prism::Tui::LayoutPreset::Automatic); require(stacked.resolvedPreset == Prism::Tui::LayoutPreset::Stacked, "short terminals should stack their panels"); require(stacked.panels.size() == 2 && @@ -182,11 +198,250 @@ void testProjectionAndLayout() { expanded.panels[0].width == 100 && expanded.panels[0].height == 28, "expanded panels should occupy the complete dashboard area"); require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum) == - Prism::Tui::PanelId::Levels, + Prism::Tui::PanelId::Oscilloscope, "panel focus should cycle forward"); require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) == Prism::Tui::PanelId::Levels, "panel focus should cycle backward"); + const auto compactPanels = Prism::Tui::visiblePanelOrder(stacked); + require(compactPanels.size() == 2 && + Prism::Tui::nextPanel( + Prism::Tui::PanelId::Spectrum, compactPanels) == Prism::Tui::PanelId::Levels, + "compact layout focus should skip hidden visual scopes"); +} + +void testSpectrumPeakModel() { + constexpr float sampleRate = 48000.0f; + constexpr size_t fftSize = 4096; + const float targetBin = 440.0f * static_cast(fftSize) / sampleRate; + std::vector magnitudes(fftSize / 2, -100.0f); + for (size_t bin = 1; bin + 1 < magnitudes.size(); ++bin) { + const float distance = static_cast(bin) - targetBin; + magnitudes[bin] = std::max(-100.0f, -12.0f - 4.0f * distance * distance); + } + + Prism::Tui::SpectrumPeakTracker tracker; + const auto peak = tracker.select(magnitudes, sampleRate, fftSize, 2.0f); + require(peak.has_value(), "a deterministic spectral peak should be detected"); + require(std::abs(peak->frequencyHz - 440.0f) < 1.0f, + "quadratic peak interpolation should recover sub-bin frequency"); + require(std::abs(peak->dbfs + 12.0f) < 0.1f, + "peak readout should preserve the untilted dBFS value"); + require(peak->pitch.find("A4") == 0, + "peak readout should include its musical pitch"); + require(Prism::Tui::formatSpectrumPitch(261.6256f).find("C4") == 0, + "pitch formatting should use conventional note and octave names"); + + tracker.reset(); + std::fill(magnitudes.begin(), magnitudes.end(), -100.0f); + require(!tracker.select(magnitudes, sampleRate, fftSize, 2.0f), + "silent spectra should not produce a peak readout"); +} + +void testSettingsModelAndPersistence() { + Prism::Tui::TuiSettings settings; + settings.inputTrimDb = 30.0f; + settings.refreshRate = 42; + settings.spectrumTiltDbPerOctave = -8.0f; + settings.oscilloscopeTraceWeight = 20; + settings = Prism::Tui::normalizeSettings(settings); + require(settings.inputTrimDb == 12.0f && settings.refreshRate == 60 && + settings.spectrumTiltDbPerOctave == -2.0f && + settings.oscilloscopeTraceWeight == 3, + "settings normalization should enforce public ranges"); + + const auto pages = Prism::Tui::settingsPages(); + require(pages.size() == 4 && + Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 3, + "settings should expose shallow category pages"); + Prism::Tui::TuiSettings adjusted; + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::InputTrim, 1) && + adjusted.inputTrimDb == 0.5f, + "numeric settings should adjust by their documented step"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::SpectrumPeakReadout, 1) && + !adjusted.spectrumPeakReadout, + "boolean settings should toggle directly"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::OscilloscopePitchLock, 1) && + !adjusted.oscilloscopePitchLock && + !adjusted.oscilloscopeFrequencyReadout, + "disabling pitch lock should also disable its frequency readout"); + require(!Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::OscilloscopeFrequencyReadout, 1) && + !adjusted.oscilloscopeFrequencyReadout, + "frequency readout should remain unavailable without pitch lock"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::OscilloscopePitchLock, 1) && + adjusted.oscilloscopePitchLock, + "pitch lock should remain independently re-enableable"); + require(Prism::Tui::resetSetting( + adjusted, Prism::Tui::SettingId::InputTrim) && + adjusted.inputTrimDb == 0.0f, + "individual settings should reset to defaults"); + + const auto settingsPath = std::filesystem::temp_directory_path() / + "prism-tui-settings-test.conf"; + std::error_code ignored; + std::filesystem::remove(settingsPath, ignored); + adjusted.layoutPreset = Prism::Tui::LayoutPreset::Columns; + adjusted.vectorscopeMode = Prism::Tui::VectorscopeMode::PolarBipolar; + adjusted.vectorscopeDetail = Prism::Tui::VectorscopeDetail::Maximum; + std::string error; + require(Prism::Tui::saveSettings(adjusted, settingsPath, &error), + "settings should persist to a TUI-specific configuration file"); + require(Prism::Tui::loadSettings(settingsPath) == adjusted, + "persisted settings should round-trip without changing values"); + std::filesystem::remove(settingsPath, ignored); +} + +void testScopePlotModels() { + const auto oscilloscope = Prism::Tui::buildOscilloscopePlot( + {-1.0f, 0.0f, 1.0f}, 9, 9); + require(oscilloscope.size() == 9, + "oscilloscope projection should fill every Braille pixel column"); + require(oscilloscope.front().y == 8 && oscilloscope.back().y == 0, + "oscilloscope projection should preserve full-scale polarity"); + require(std::all_of( + oscilloscope.begin(), oscilloscope.end(), [](const Prism::Tui::PlotPoint& point) { + return point.x >= 0 && point.x < 9 && point.y >= 0 && point.y < 9; + }), "oscilloscope projection should remain bounded"); + const auto zeroLine = Prism::Tui::buildOscilloscopePlot({0.0f}, 1, 8); + require(zeroLine.front().y == Prism::Tui::oscilloscopeZeroY(8) && + zeroLine.front().y == 4, + "the oscilloscope zero line should use the waveform's center rounding"); + + const std::vector multiband = { + 1.0f, 0.0f, + 0.0f, 0.5f, + -1.0f, -0.5f, + }; + const auto vectorscope = Prism::Tui::buildVectorscopePlot( + multiband, 1, 21, 21); + require(vectorscope[0].size() == 1 && + vectorscope[1].size() == 1 && + vectorscope[2].size() == 1, + "vectorscope projection should preserve all three frequency bands"); + for (const auto& band : vectorscope) { + require(std::all_of( + band.begin(), band.end(), [](const Prism::Tui::PlotPoint& point) { + return point.x >= 0 && point.x < 21 && point.y >= 0 && point.y < 21; + }), "vectorscope projection should remain bounded"); + } + require(Prism::Tui::buildOscilloscopePlot({}, 10, 10).empty(), + "an empty oscilloscope frame should render no points"); + + auto mode = Prism::Tui::VectorscopeMode::Lissajous; + for (int index = 0; index < 5; ++index) { + require(std::string(Prism::Tui::vectorscopeModeName(mode)).size() > 0, + "each vectorscope mode should have a display name"); + mode = Prism::Tui::nextVectorscopeMode(mode); + } + require(mode == Prism::Tui::VectorscopeMode::Lissajous, + "vectorscope mode selection should cycle through all five modes"); + + const std::vector correlated = { + 0.25f, 0.25f, + 0.25f, 0.25f, + 0.25f, 0.25f, + }; + const auto linear = Prism::Tui::buildVectorscopePlot( + correlated, + 1, + 41, + 41, + Prism::Tui::VectorscopeMode::LinearBipolar); + const auto polar = Prism::Tui::buildVectorscopePlot( + correlated, + 1, + 41, + 41, + Prism::Tui::VectorscopeMode::PolarBipolar); + const auto centeredLayout = Prism::Tui::getVectorscopePlotLayout( + 41, 41, Prism::Tui::VectorscopeMode::LinearBipolar); + require(linear[0].front().x == centeredLayout.centerX && + linear[0].front().y < centeredLayout.centerY, + "linear vectorscope mode should rotate correlated stereo onto the mono axis"); + require(polar[0].front().y < linear[0].front().y, + "polar vectorscope mode should expand quiet points radially"); + + const std::vector negativeMid = { + -0.5f, -0.5f, + -0.5f, -0.5f, + -0.5f, -0.5f, + }; + const auto unipolar = Prism::Tui::buildVectorscopePlot( + negativeMid, + 1, + 41, + 41, + Prism::Tui::VectorscopeMode::PolarUnipolar); + require(unipolar[0].empty() && unipolar[1].empty() && unipolar[2].empty(), + "unipolar vectorscope modes should omit negative-mid points"); + const auto unipolarLayout = Prism::Tui::getVectorscopePlotLayout( + 41, 41, Prism::Tui::VectorscopeMode::PolarUnipolar); + require(unipolarLayout.unipolar && + unipolarLayout.centerY > centeredLayout.centerY, + "unipolar vectorscope modes should use the lower display origin"); + + std::vector denseMultiband(300 * Visualizer::MULTIBAND_POINT_STRIDE, 0.2f); + const auto detailPreserving = Prism::Tui::buildVectorscopePlot( + denseMultiband, + 300, + 12, + 12, + Prism::Tui::VectorscopeMode::Lissajous); + for (const auto& band : detailPreserving) { + require(band.size() <= 64, + "vectorscope projection should adapt its point budget to terminal resolution"); + require(!band.empty() && band.front().intensity < band.back().intensity && + band.back().intensity == 1.0f, + "vectorscope projection should retain chronological intensity information"); + } + const auto balancedDetail = Prism::Tui::buildVectorscopePlot( + denseMultiband, 300, 30, 30, Prism::Tui::VectorscopeMode::Lissajous, 10); + const auto maximumDetail = Prism::Tui::buildVectorscopePlot( + denseMultiband, 300, 30, 30, Prism::Tui::VectorscopeMode::Lissajous, 3); + require(balancedDetail[0].size() < maximumDetail[0].size(), + "vectorscope detail settings should change the adaptive point budget"); +} + +void testPitchReadoutResponse() { + constexpr float sampleRate = 48000.0f; + Visualizer::Oscilloscope oscilloscope; + oscilloscope.setSampleRate(sampleRate); + oscilloscope.setPitchLock(true); + + const auto lowTone = sineChunk(100.0f, 0.5f, 4096, sampleRate); + oscilloscope.pushSamples(lowTone.left.data(), lowTone.left.size()); + for (int index = 0; index < 24; ++index) { + oscilloscope.process(); + } + + const auto highTone = sineChunk(400.0f, 0.5f, 4096, sampleRate); + oscilloscope.pushSamples(highTone.left.data(), highTone.left.size()); + const auto locked = oscilloscope.process(); + const float latest = oscilloscope.getLatestDetectedPitch(); + require(latest > 0.0f && + std::abs(latest - 400.0f) < std::abs(locked.detectedPitch - 400.0f), + "the fast pitch readout should respond before the stable trigger pitch"); + + Visualizer::Oscilloscope freeRunning; + freeRunning.setSampleRate(sampleRate); + freeRunning.setPitchLock(false); + freeRunning.setDisplaySamples(128); + const auto firstChunk = sineChunk(200.0f, 0.5f, 512, sampleRate); + freeRunning.pushSamples(firstChunk.left.data(), firstChunk.left.size()); + const auto firstWindow = freeRunning.process(); + require(firstWindow.triggerIndex == 384.0f, + "free-running oscilloscopes should show the newest complete window"); + const auto nextChunk = sineChunk(200.0f, 0.5f, 64, sampleRate); + freeRunning.pushSamples(nextChunk.left.data(), nextChunk.left.size()); + const auto nextWindow = freeRunning.process(); + require(nextWindow.triggerIndex == 448.0f && + nextWindow.triggerIndex != firstWindow.triggerIndex, + "free-running oscilloscope windows should advance with every audio chunk"); } void testPipelineAndFakeCapture() { @@ -216,6 +471,26 @@ void testPipelineAndFakeCapture() { const auto frame = pipeline.snapshot(); require(frame.magnitudes.size() == Prism::Tui::kDefaultFftSize / 2, "the default analysis pipeline should publish the 4096-point spectrum"); + require(frame.oscilloscope.samples.size() == 2048 && + frame.oscilloscope.signalPresent, + "the pipeline should publish a live pitch-locked oscilloscope window"); + require(std::isfinite(frame.oscilloscope.detectedPitch) && + frame.oscilloscope.detectedPitch > 0.0f, + "the pipeline should publish the fast pitch readout"); + require(std::all_of( + frame.oscilloscope.samples.begin(), + frame.oscilloscope.samples.end(), + [](float sample) { return std::isfinite(sample); }), + "oscilloscope samples should remain finite"); + require(frame.vectorscope.pointCount == Prism::Tui::kVectorscopeDisplayPoints && + frame.vectorscope.multibandPoints.size() == + frame.vectorscope.pointCount * Visualizer::MULTIBAND_POINT_STRIDE, + "the pipeline should publish a full multiband vectorscope frame"); + require(std::all_of( + frame.vectorscope.multibandPoints.begin(), + frame.vectorscope.multibandPoints.end(), + [](float sample) { return std::isfinite(sample); }), + "vectorscope samples should remain finite"); require(frame.vu.barLDb > -20.0f && frame.vu.barLDb < -5.0f, "VU level should reflect deterministic input"); require(std::isfinite(frame.lufs.momentaryLUFS) && frame.lufs.momentaryLUFS > -60.0f, @@ -225,6 +500,17 @@ void testPipelineAndFakeCapture() { require(std::abs(frame.lufs.integratedLUFS + 12.03f) < 0.5f, "integrated LUFS should match the deterministic stereo tone"); + Prism::Tui::AnalysisPipeline trimmedPipeline(48000.0f); + trimmedPipeline.setInputTrimDb(6.0f); + for (int index = 0; index < 20; ++index) { + trimmedPipeline.process(sineChunk(1000.0f, 0.25f, 2400, 48000.0f)); + } + const auto trimmed = trimmedPipeline.snapshot(); + require(std::abs((trimmed.vu.barLDb - frame.vu.barLDb) - 6.0f) < 0.35f, + "input trim should affect the real VU analyzer before processing"); + require(std::abs((trimmed.lufs.momentaryLUFS - frame.lufs.momentaryLUFS) - 6.0f) < 0.35f, + "input trim should affect the real loudness analyzer before processing"); + Prism::Tui::AnalysisPipeline stereoPipeline(48000.0f); for (int index = 0; index < 20; ++index) { stereoPipeline.process(stereoSineChunk(1000.0f, 0.5f, 0.125f, 2400, 48000.0f)); @@ -235,6 +521,12 @@ void testPipelineAndFakeCapture() { pipeline.reset(); const auto reset = pipeline.snapshot(); require(reset.lufs.integratedLUFS <= -59.0f, "reset should clear integrated loudness"); + require(!reset.oscilloscope.signalPresent, + "reset should clear the oscilloscope display window"); + require(reset.oscilloscope.detectedPitch == 0.0f, + "reset should clear the fast pitch readout"); + require(reset.vectorscope.pointCount == 0 && reset.vectorscope.multibandPoints.empty(), + "reset should clear vectorscope history"); require(capture.stopped, "fake capture should stop cleanly"); } @@ -260,6 +552,10 @@ void testThreadSafeSnapshots() { int main() { testCli(); testProjectionAndLayout(); + testSpectrumPeakModel(); + testSettingsModelAndPersistence(); + testScopePlotModels(); + testPitchReadoutResponse(); testPipelineAndFakeCapture(); testThreadSafeSnapshots(); std::cout << "Prism TUI tests passed\n";