From fbec2a5aa8d1b9684f5314cdf4ae46b4f2f9917a Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:58:44 -0400 Subject: [PATCH] spectrogram and waveform --- tui/CMakeLists.txt | 3 + tui/src/analysis_pipeline.cpp | 81 ++++++++- tui/src/analysis_pipeline.h | 36 +++- tui/src/cli.cpp | 3 +- tui/src/dashboard_layout.cpp | 21 +++ tui/src/dashboard_layout.h | 2 + tui/src/scrolling_history.cpp | 71 ++++++++ tui/src/scrolling_history.h | 36 ++++ tui/src/tui_runtime.cpp | 333 +++++++++++++++++++++++++++++++++- tui/src/tui_settings.cpp | 241 +++++++++++++++++++++++- tui/src/tui_settings.h | 34 ++++ tui/test/tui_tests.cpp | 93 +++++++++- 12 files changed, 943 insertions(+), 11 deletions(-) create mode 100644 tui/src/scrolling_history.cpp create mode 100644 tui/src/scrolling_history.h diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index 3c6b7a3..4eb2edd 100644 --- a/tui/CMakeLists.txt +++ b/tui/CMakeLists.txt @@ -34,6 +34,7 @@ add_library(prism_tui_analysis STATIC src/dashboard_layout.cpp src/display_model.cpp src/meter_display_model.cpp + src/scrolling_history.cpp src/scope_plot_model.cpp src/spectrum_peak_model.cpp src/tui_settings.cpp @@ -43,6 +44,8 @@ add_library(prism_tui_analysis STATIC ${PRISM_NATIVE_DIR}/multiband.cpp ${PRISM_NATIVE_DIR}/vumeter.cpp ${PRISM_NATIVE_DIR}/lufsmeter.cpp + ${PRISM_NATIVE_DIR}/spectrogram.cpp + ${PRISM_NATIVE_DIR}/waveform.cpp ${PRISM_NATIVE_DIR}/dsp_utils.cpp) target_include_directories(prism_tui_analysis PUBLIC src diff --git a/tui/src/analysis_pipeline.cpp b/tui/src/analysis_pipeline.cpp index cad275e..b036c6c 100644 --- a/tui/src/analysis_pipeline.cpp +++ b/tui/src/analysis_pipeline.cpp @@ -25,7 +25,13 @@ int normalizedOscilloscopeDisplaySamples(float sampleRate) { } // namespace AnalysisPipeline::AnalysisPipeline(float sampleRate, size_t fftSize) - : spectrum_(fftSize), sampleRate_(sampleRate), fftSize_(fftSize) { + : spectrum_(fftSize), + spectrogramDisplayHistory_(kSpectrogramHistoryRows, kSpectrogramHistoryColumns), + spectrogramHeatHistory_(kSpectrogramHistoryRows, kSpectrogramHistoryColumns), + waveformHistory_(Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE, + kWaveformHistoryColumns), + sampleRate_(sampleRate), + fftSize_(fftSize) { spectrum_.setSampleRate(sampleRate); spectrum_.setSmoothing(0.9f); vu_.setSampleRate(sampleRate); @@ -34,6 +40,8 @@ AnalysisPipeline::AnalysisPipeline(float sampleRate, size_t fftSize) oscilloscope_.setPitchLock(true); oscilloscope_.setDisplaySamples(normalizedOscilloscopeDisplaySamples(sampleRate)); vectorscope_.setSampleRate(sampleRate); + setSpectrogramSettings(2.0f, 1.0f, 4.0f, "sharper", "log", "horizontal"); + setWaveformSettings(false, 1); } void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) { @@ -62,6 +70,30 @@ void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) { } oscilloscope_.pushSamples(monoScratch_.data(), count); vectorscope_.pushMultibandSamples(left, right, count); + + const auto spectrogramColumns = spectrogram_.process(monoScratch_.data(), count); + spectrogramDisplayHistory_.append(spectrogramColumns.display); + spectrogramHeatHistory_.append(spectrogramColumns.heat); + + if (waveformStereo_) { + const auto& columns = waveform_.processStereo(left, right, count); + waveformHistory_.append(columns); + } else { + const auto& columns = waveform_.processMono(monoScratch_.data(), count); + const size_t columnCount = columns.size() / Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE; + waveformMonoScratch_.resize( + columnCount * Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE); + for (size_t column = 0; column < columnCount; ++column) { + const size_t source = column * Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE; + const size_t destination = column * Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE; + for (size_t value = 0; value < Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE; ++value) { + waveformMonoScratch_[destination + value] = columns[source + value]; + waveformMonoScratch_[destination + Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE + value] = + columns[source + value]; + } + } + waveformHistory_.append(waveformMonoScratch_); + } } AnalysisFrame AnalysisPipeline::snapshot() { @@ -102,6 +134,10 @@ AnalysisFrame AnalysisPipeline::snapshot() { kVectorscopeDisplayPoints); frame.vectorscope.multibandPoints.resize( frame.vectorscope.pointCount * Visualizer::MULTIBAND_POINT_STRIDE); + frame.spectrogram.display = spectrogramDisplayHistory_.snapshot(); + frame.spectrogram.heat = spectrogramHeatHistory_.snapshot(); + frame.waveform.history = waveformHistory_.snapshot(); + frame.waveform.stereo = waveformStereo_; return frame; } @@ -111,10 +147,16 @@ void AnalysisPipeline::reset() { lufs_.reset(); oscilloscope_.reset(); vectorscope_.reset(); + spectrogram_.reset(); + waveform_.reset(); + spectrogramDisplayHistory_.reset(); + spectrogramHeatHistory_.reset(); + waveformHistory_.reset(); spectrumPeakTracker_.reset(); monoScratch_.clear(); trimmedLeftScratch_.clear(); trimmedRightScratch_.clear(); + waveformMonoScratch_.clear(); displayPitch_ = 0.0f; } @@ -133,6 +175,43 @@ void AnalysisPipeline::setOscilloscopePitchLock(bool enabled) { oscilloscope_.setPitchLock(enabled); } +void AnalysisPipeline::setSpectrogramSettings(float scrollSpeed, + float contrast, + float tiltDbPerOctave, + const std::string& clarityMode, + const std::string& scaleMode, + const std::string& orientation) { + Visualizer::SpectrogramConfig config; + config.fftSize = fftSize_; + config.sampleRate = sampleRate_; + config.rowCount = kSpectrogramHistoryRows; + config.minFrequency = 20.0f; + config.maxFrequency = 20000.0f; + config.minDecibels = -90.0f; + config.maxDecibels = -12.0f; + config.scrollSpeed = std::clamp(scrollSpeed, 0.5f, 4.0f); + config.contrast = std::clamp(contrast, 0.5f, 2.0f); + config.tiltDbPerOctave = std::clamp(tiltDbPerOctave, -2.0f, 8.0f); + config.clarityMode = clarityMode; + config.scaleMode = scaleMode; + config.orientation = orientation; + spectrogram_.configure(config); + spectrogramDisplayHistory_.reset(); + spectrogramHeatHistory_.reset(); +} + +void AnalysisPipeline::setWaveformSettings(bool stereo, int scrollSpeed) { + waveformStereo_ = stereo; + waveformScrollSpeed_ = std::clamp(scrollSpeed, 1, 8); + constexpr float baseColumnsPerSecond = 128.0f; + const size_t samplesPerColumn = static_cast(std::max( + 1.0f, + std::round(sampleRate_ / + (baseColumnsPerSecond * static_cast(waveformScrollSpeed_))))); + waveform_.configure(sampleRate_, samplesPerColumn); + waveformHistory_.reset(); +} + size_t drainCapture(Prism::Capture::SystemAudioCapture& capture, AnalysisPipeline& pipeline, bool& captureOverrun, diff --git a/tui/src/analysis_pipeline.h b/tui/src/analysis_pipeline.h index 146b386..3a6f681 100644 --- a/tui/src/analysis_pipeline.h +++ b/tui/src/analysis_pipeline.h @@ -2,19 +2,26 @@ #include "lufsmeter.h" #include "oscilloscope.h" +#include "scrolling_history.h" +#include "spectrogram.h" #include "spectrum.h" #include "spectrum_peak_model.h" #include "system_audio_capture.h" #include "vectorscope.h" #include "vumeter.h" +#include "waveform.h" -#include #include +#include +#include namespace Prism::Tui { constexpr size_t kDefaultFftSize = 4096; constexpr size_t kVectorscopeDisplayPoints = 4096; +constexpr size_t kSpectrogramHistoryRows = 128; +constexpr size_t kSpectrogramHistoryColumns = 1024; +constexpr size_t kWaveformHistoryColumns = 2048; struct OscilloscopeFrame { std::vector samples; @@ -27,6 +34,16 @@ struct VectorscopeFrame { size_t pointCount = 0; }; +struct SpectrogramFrame { + ScrollingHistoryFrame display; + ScrollingHistoryFrame heat; +}; + +struct WaveformFrame { + ScrollingHistoryFrame history; + bool stereo = false; +}; + struct AnalysisFrame { std::vector magnitudes; std::optional spectrumPeak; @@ -34,6 +51,8 @@ struct AnalysisFrame { Visualizer::LUFSMeterSnapshot lufs{}; OscilloscopeFrame oscilloscope; VectorscopeFrame vectorscope; + SpectrogramFrame spectrogram; + WaveformFrame waveform; }; class AnalysisPipeline { @@ -46,6 +65,13 @@ public: void setInputTrimDb(float db); void setSpectrumTilt(float dbPerOctave); void setOscilloscopePitchLock(bool enabled); + void setSpectrogramSettings(float scrollSpeed, + float contrast, + float tiltDbPerOctave, + const std::string& clarityMode, + const std::string& scaleMode, + const std::string& orientation); + void setWaveformSettings(bool stereo, int scrollSpeed); private: Visualizer::Spectrum spectrum_; @@ -53,15 +79,23 @@ private: Visualizer::LUFSMeterAnalyzer lufs_; Visualizer::Oscilloscope oscilloscope_; Visualizer::Vectorscope vectorscope_; + Visualizer::SpectrogramAnalyzer spectrogram_; + Visualizer::WaveformMultibandAnalyzer waveform_; + ScrollingHistory spectrogramDisplayHistory_; + ScrollingHistory spectrogramHeatHistory_; + ScrollingHistory waveformHistory_; SpectrumPeakTracker spectrumPeakTracker_; std::vector monoScratch_; std::vector trimmedLeftScratch_; std::vector trimmedRightScratch_; + std::vector waveformMonoScratch_; float sampleRate_ = 48000.0f; size_t fftSize_ = kDefaultFftSize; float inputGainLinear_ = 1.0f; float spectrumTiltDbPerOctave_ = 2.0f; float displayPitch_ = 0.0f; + bool waveformStereo_ = false; + int waveformScrollSpeed_ = 1; }; size_t drainCapture(Prism::Capture::SystemAudioCapture& capture, diff --git a/tui/src/cli.cpp b/tui/src/cli.cpp index 9c1f094..d5b8bbd 100644 --- a/tui/src/cli.cpp +++ b/tui/src/cli.cpp @@ -60,10 +60,11 @@ 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" + " s Open settings.\n" " l Cycle automatic, stacked, and column layouts.\n" " v Cycle vectorscope display modes.\n" " 1 / 2 / 3 / 4 / 5 Focus Spectrum, Oscilloscope, Vectorscope, VU, or LUFS.\n" + " 6 / 7 Focus Spectrogram or Waveform.\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 538e7f0..7706cf2 100644 --- a/tui/src/dashboard_layout.cpp +++ b/tui/src/dashboard_layout.cpp @@ -24,6 +24,10 @@ MinimumSize panelMinimumSize(PanelId panel) { return {30, 5}; case PanelId::LUFSMeter: return {30, 7}; + case PanelId::Spectrogram: + return {30, 8}; + case PanelId::Waveform: + return {30, 7}; } return {1, 1}; } @@ -155,6 +159,21 @@ LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) { LayoutNode makeRoot(LayoutPreset preset, int width, int height) { if (preset == LayoutPreset::Columns) { + if (width >= 108 && height >= 34) { + return LayoutNode::split(SplitAxis::Columns, { + LayoutNode::split(SplitAxis::Rows, { + LayoutNode::leaf(PanelId::Spectrum, 3), + LayoutNode::leaf(PanelId::Oscilloscope, 2), + LayoutNode::leaf(PanelId::Waveform, 2), + }, 3), + LayoutNode::split(SplitAxis::Rows, { + LayoutNode::leaf(PanelId::Vectorscope, 2), + LayoutNode::leaf(PanelId::VUMeter, 1), + LayoutNode::leaf(PanelId::LUFSMeter, 1), + LayoutNode::leaf(PanelId::Spectrogram, 2), + }, 1), + }); + } return LayoutNode::split(SplitAxis::Columns, { LayoutNode::split(SplitAxis::Rows, { LayoutNode::leaf(PanelId::Spectrum, 3), @@ -253,6 +272,8 @@ std::vector panelOrder() { PanelId::Vectorscope, PanelId::VUMeter, PanelId::LUFSMeter, + PanelId::Spectrogram, + PanelId::Waveform, }; } diff --git a/tui/src/dashboard_layout.h b/tui/src/dashboard_layout.h index f1cf61f..77dfc59 100644 --- a/tui/src/dashboard_layout.h +++ b/tui/src/dashboard_layout.h @@ -12,6 +12,8 @@ enum class PanelId { Vectorscope, VUMeter, LUFSMeter, + Spectrogram, + Waveform, }; enum class SplitAxis { diff --git a/tui/src/scrolling_history.cpp b/tui/src/scrolling_history.cpp new file mode 100644 index 0000000..1898e96 --- /dev/null +++ b/tui/src/scrolling_history.cpp @@ -0,0 +1,71 @@ +#include "scrolling_history.h" + +#include +#include + +namespace Prism::Tui { + +ScrollingHistory::ScrollingHistory(size_t columnStride, size_t capacityColumns) + : columnStride_(columnStride), + capacityColumns_(capacityColumns), + values_(columnStride * capacityColumns, 0.0f) { + if (columnStride == 0 || capacityColumns == 0) { + throw std::invalid_argument("scrolling history dimensions must be non-zero"); + } +} + +void ScrollingHistory::append(const float* values, size_t columnCount) { + if (values == nullptr || columnCount == 0) { + return; + } + if (columnCount >= capacityColumns_) { + values += (columnCount - capacityColumns_) * columnStride_; + columnCount = capacityColumns_; + std::copy(values, + values + columnCount * columnStride_, + values_.begin()); + columnCount_ = capacityColumns_; + writeColumn_ = 0; + return; + } + + for (size_t column = 0; column < columnCount; ++column) { + const size_t destination = writeColumn_ * columnStride_; + const size_t source = column * columnStride_; + std::copy(values + source, + values + source + columnStride_, + values_.begin() + static_cast(destination)); + writeColumn_ = (writeColumn_ + 1) % capacityColumns_; + columnCount_ = std::min(columnCount_ + 1, capacityColumns_); + } +} + +void ScrollingHistory::append(const std::vector& values) { + append(values.data(), values.size() / columnStride_); +} + +ScrollingHistoryFrame ScrollingHistory::snapshot() const { + ScrollingHistoryFrame frame; + frame.columnCount = columnCount_; + frame.columnStride = columnStride_; + frame.values.resize(columnCount_ * columnStride_); + if (columnCount_ == 0) { + return frame; + } + + const size_t oldest = columnCount_ == capacityColumns_ ? writeColumn_ : 0; + for (size_t column = 0; column < columnCount_; ++column) { + const size_t sourceColumn = (oldest + column) % capacityColumns_; + std::copy(values_.begin() + static_cast(sourceColumn * columnStride_), + values_.begin() + static_cast((sourceColumn + 1) * columnStride_), + frame.values.begin() + static_cast(column * columnStride_)); + } + return frame; +} + +void ScrollingHistory::reset() { + columnCount_ = 0; + writeColumn_ = 0; +} + +} // namespace Prism::Tui diff --git a/tui/src/scrolling_history.h b/tui/src/scrolling_history.h new file mode 100644 index 0000000..2270e89 --- /dev/null +++ b/tui/src/scrolling_history.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +namespace Prism::Tui { + +struct ScrollingHistoryFrame { + std::vector values; + size_t columnCount = 0; + size_t columnStride = 0; +}; + +// Fixed-capacity column storage. Audio processing appends into the ring while +// snapshots expose a stable, chronological copy to the renderer. +class ScrollingHistory { +public: + ScrollingHistory(size_t columnStride, size_t capacityColumns); + + void append(const float* values, size_t columnCount); + void append(const std::vector& values); + ScrollingHistoryFrame snapshot() const; + void reset(); + + size_t columnCount() const { return columnCount_; } + size_t columnStride() const { return columnStride_; } + +private: + size_t columnStride_ = 0; + size_t capacityColumns_ = 0; + size_t columnCount_ = 0; + size_t writeColumn_ = 0; + std::vector values_; +}; + +} // namespace Prism::Tui diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index 78543c8..951c593 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -47,6 +47,48 @@ std::chrono::microseconds displayFrameInterval(int framesPerSecond) { 1000000 / std::max(1, framesPerSecond)); } +const char* spectrogramClarityConfig(SpectrogramClarity clarity) { + switch (clarity) { + case SpectrogramClarity::Classic: return "classic"; + case SpectrogramClarity::Sharp: return "sharp"; + case SpectrogramClarity::Sharper: return "sharper"; + } + return "sharper"; +} + +const char* spectrogramScaleConfig(SpectrogramScale scale) { + switch (scale) { + case SpectrogramScale::Mel: return "mel"; + case SpectrogramScale::Logarithmic: return "log"; + case SpectrogramScale::Linear: return "linear"; + } + return "log"; +} + +const char* spectrogramOrientationConfig(SpectrogramOrientation orientation) { + return orientation == SpectrogramOrientation::Vertical + ? "vertical" + : "horizontal"; +} + +void applySpectrogramSettings(AnalysisPipeline& pipeline, + const TuiSettings& settings) { + pipeline.setSpectrogramSettings( + settings.spectrogramScrollSpeed, + settings.spectrogramContrast, + settings.spectrogramTiltDbPerOctave, + spectrogramClarityConfig(settings.spectrogramClarity), + spectrogramScaleConfig(settings.spectrogramScale), + spectrogramOrientationConfig(settings.spectrogramOrientation)); +} + +void applyWaveformSettings(AnalysisPipeline& pipeline, + const TuiSettings& settings) { + pipeline.setWaveformSettings( + settings.waveformMode == WaveformMode::Stereo, + settings.waveformScrollSpeed); +} + volatile std::sig_atomic_t signalRequested = 0; void handleSignal(int) { @@ -77,6 +119,8 @@ struct DisplayFrame { Visualizer::LUFSMeterSnapshot lufs{}; OscilloscopeFrame oscilloscope; VectorscopeFrame vectorscope; + SpectrogramFrame spectrogram; + WaveformFrame waveform; double sampleRate = 48000.0; std::string backend; std::string device; @@ -90,7 +134,7 @@ struct InterfaceState { bool settingsOpen = false; SettingsPage settingsPage = SettingsPage::Home; size_t settingsHomeSelection = 0; - std::array settingsSelections{}; + std::array settingsSelections{}; std::string settingsStatus; }; @@ -157,6 +201,10 @@ std::string panelName(PanelId panel) { return "VU Meter"; case PanelId::LUFSMeter: return "LUFS Meter"; + case PanelId::Spectrogram: + return "Spectrogram"; + case PanelId::Waveform: + return "Waveform"; } return "Panel"; } @@ -173,6 +221,10 @@ std::string panelNumber(PanelId panel) { return "4"; case PanelId::LUFSMeter: return "5"; + case PanelId::Spectrogram: + return "6"; + case PanelId::Waveform: + return "7"; } return "?"; } @@ -907,6 +959,247 @@ ftxui::Element renderLUFSMeterPanel(const DisplayFrame& frame, size(HEIGHT, EQUAL, std::max(1, height)); } +ftxui::Color interpolateColor(const std::array& from, + const std::array& to, + float amount) { + const float t = std::clamp(amount, 0.0f, 1.0f); + return ftxui::Color::RGB( + static_cast(std::lround(from[0] + (to[0] - from[0]) * t)), + static_cast(std::lround(from[1] + (to[1] - from[1]) * t)), + static_cast(std::lround(from[2] + (to[2] - from[2]) * t))); +} + +ftxui::Color spectrogramColor(float intensity, SpectrogramColorMode mode) { + const float value = std::clamp(intensity, 0.0f, 1.0f); + if (mode == SpectrogramColorMode::Mono) { + return interpolateColor( + {{5, 12, 14}}, {{102, 255, 255}}, std::pow(value, 0.72f)); + } + + constexpr std::array stops = {0.0f, 0.16f, 0.36f, 0.58f, 0.78f, 1.0f}; + constexpr std::array, 6> colors = {{ + {{5, 3, 12}}, + {{15, 7, 33}}, + {{61, 11, 94}}, + {{163, 26, 121}}, + {{255, 82, 87}}, + {{255, 241, 209}}, + }}; + size_t upper = 1; + while (upper + 1 < stops.size() && value > stops[upper]) ++upper; + const size_t lower = upper - 1; + const float span = stops[upper] - stops[lower]; + return interpolateColor( + colors[lower], colors[upper], + span > 0.0f ? (value - stops[lower]) / span : 0.0f); +} + +float maxHistoryValue(const ScrollingHistoryFrame& history, + size_t column, + size_t firstRow, + size_t lastRow) { + if (column >= history.columnCount || history.columnStride == 0 || + history.values.size() < history.columnCount * history.columnStride) { + return 0.0f; + } + firstRow = std::min(firstRow, history.columnStride - 1); + lastRow = std::min(std::max(firstRow + 1, lastRow), history.columnStride); + float result = 0.0f; + const size_t offset = column * history.columnStride; + for (size_t row = firstRow; row < lastRow; ++row) { + result = std::max(result, history.values[offset + row]); + } + return result; +} + +ftxui::Element renderSpectrogramPanel(const DisplayFrame& frame, + int width, + int height, + bool focused, + const TuiSettings& settings) { + using namespace ftxui; + const bool heat = settings.spectrogramColor == SpectrogramColorMode::Heat; + const auto& history = heat ? frame.spectrogram.heat : frame.spectrogram.display; + const bool vertical = + settings.spectrogramOrientation == SpectrogramOrientation::Vertical; + auto plot = canvas([ + history, + colorMode = settings.spectrogramColor, + vertical + ](Canvas& surface) { + const int cellColumns = surface.width() / 2; + const int cellRows = surface.height() / 4; + if (cellColumns <= 0 || cellRows <= 0 || history.columnCount == 0 || + history.columnStride == 0) { + return; + } + + const int timeCells = vertical ? cellRows : cellColumns; + const int frequencyCells = vertical ? cellColumns : cellRows; + const size_t visibleColumns = std::min( + history.columnCount, static_cast(timeCells)); + const size_t sourceStart = history.columnCount - visibleColumns; + const int destinationStart = timeCells - static_cast(visibleColumns); + for (size_t time = 0; time < visibleColumns; ++time) { + const size_t sourceColumn = sourceStart + time; + const int timeCell = destinationStart + static_cast(time); + for (int frequencyCell = 0; frequencyCell < frequencyCells; ++frequencyCell) { + const float normalizedStart = static_cast(frequencyCell) / + static_cast(frequencyCells); + const float normalizedEnd = static_cast(frequencyCell + 1) / + static_cast(frequencyCells); + const size_t firstRow = static_cast(std::floor( + normalizedStart * static_cast(history.columnStride))); + const size_t lastRow = static_cast(std::ceil( + normalizedEnd * static_cast(history.columnStride))); + const float intensity = maxHistoryValue( + history, sourceColumn, firstRow, lastRow); + if (!std::isfinite(intensity) || intensity < 0.008f) continue; + + const int cellX = vertical ? frequencyCell : timeCell; + const int cellY = vertical + ? timeCell + : frequencyCell; + const Color color = spectrogramColor(intensity, colorMode); + for (int dx = 0; dx < 2; ++dx) { + surface.DrawBlock(cellX * 2 + dx, cellY * 4, true, color); + surface.DrawBlock(cellX * 2 + dx, cellY * 4 + 2, true, color); + } + } + } + }) | flex; + + std::string detail = std::string(spectrogramColorName(settings.spectrogramColor)); + if (width >= 48) { + detail += " • " + std::string(spectrogramScaleName(settings.spectrogramScale)); + } + if (width >= 66) { + detail += " • " + std::string( + spectrogramOrientationName(settings.spectrogramOrientation)); + } + auto panel = window( + panelTitle(PanelId::Spectrogram, 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)); +} + +ftxui::Color waveformBandColor(const float* summary, bool multiband) { + using namespace ftxui; + if (!multiband || summary == nullptr) return Color::CyanLight; + std::array weights = { + std::max(0.0f, summary[2]), + std::max(0.0f, summary[3]), + std::max(0.0f, summary[4]), + }; + float total = weights[0] + weights[1] + weights[2]; + if (total <= 1.0e-8f) return Color::CyanLight; + for (float& weight : weights) { + weight = std::pow(weight / total, 2.6f); + } + total = weights[0] + weights[1] + weights[2]; + for (float& weight : weights) { + weight /= std::max(total, 1.0e-8f); + } + constexpr std::array, 3> colors = {{ + {{255, 68, 68}}, + {{68, 221, 68}}, + {{68, 136, 255}}, + }}; + std::array mixed{}; + for (size_t channel = 0; channel < mixed.size(); ++channel) { + for (size_t band = 0; band < weights.size(); ++band) { + mixed[channel] += static_cast(std::lround( + weights[band] * static_cast(colors[band][channel]))); + } + } + return Color::RGB( + std::clamp(mixed[0], 0, 255), + std::clamp(mixed[1], 0, 255), + std::clamp(mixed[2], 0, 255)); +} + +ftxui::Element renderWaveformPanel(const DisplayFrame& frame, + int width, + int height, + bool focused, + const TuiSettings& settings) { + using namespace ftxui; + const bool stereo = settings.waveformMode == WaveformMode::Stereo; + auto plot = canvas([ + history = frame.waveform.history, + stereo, + multiband = settings.waveformMultiband + ](Canvas& surface) { + const int canvasWidth = surface.width(); + const int canvasHeight = surface.height(); + if (canvasWidth <= 0 || canvasHeight <= 0) return; + + const int laneCount = stereo ? 2 : 1; + for (int lane = 0; lane < laneCount; ++lane) { + const int centerY = static_cast(std::lround( + (static_cast(lane) + 0.5f) * + static_cast(canvasHeight) / static_cast(laneCount))); + surface.DrawPointLine( + 0, centerY, canvasWidth - 1, centerY, + Color::RGB(55, 61, 64)); + } + if (stereo) { + surface.DrawPointLine( + 0, canvasHeight / 2, canvasWidth - 1, canvasHeight / 2, + Color::RGB(40, 45, 48)); + } + + if (history.columnCount == 0 || + history.columnStride < Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE || + history.values.size() < history.columnCount * history.columnStride) { + return; + } + const size_t visibleColumns = std::min( + history.columnCount, static_cast(canvasWidth)); + const size_t sourceStart = history.columnCount - visibleColumns; + const int destinationStart = canvasWidth - static_cast(visibleColumns); + const float laneHeight = static_cast(canvasHeight) / + static_cast(laneCount); + const float radius = std::max(1.0f, laneHeight * 0.44f); + for (size_t column = 0; column < visibleColumns; ++column) { + const size_t sourceColumn = sourceStart + column; + const float* summary = history.values.data() + + sourceColumn * history.columnStride; + const int x = destinationStart + static_cast(column); + for (int lane = 0; lane < laneCount; ++lane) { + const float* channel = summary + + (lane == 0 ? 0 : Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE); + const float minimum = std::clamp(channel[0], -1.0f, 1.0f); + const float maximum = std::clamp(channel[1], -1.0f, 1.0f); + const float centerY = (static_cast(lane) + 0.5f) * laneHeight; + const int top = std::clamp( + static_cast(std::lround(centerY - maximum * radius)), + 0, canvasHeight - 1); + const int bottom = std::clamp( + static_cast(std::lround(centerY - minimum * radius)), + 0, canvasHeight - 1); + surface.DrawBlockLine( + x, std::min(top, bottom), x, std::max(top, bottom), + waveformBandColor(channel, multiband)); + } + } + }) | flex; + + std::string detail = waveformModeName(settings.waveformMode); + if (settings.waveformMultiband) detail += " • Multiband"; + if (width >= 58) { + detail += " • " + std::to_string(settings.waveformScrollSpeed) + "×"; + } + auto panel = window( + panelTitle(PanelId::Waveform, 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)); +} + const PanelRect* findPanelRect(const DashboardLayout& layout, PanelId panel) { const auto found = std::find_if( layout.panels.begin(), layout.panels.end(), @@ -953,6 +1246,20 @@ ftxui::Element renderLayoutNode(const LayoutNode& node, rect->height, focused, state.settings); + case PanelId::Spectrogram: + return renderSpectrogramPanel( + frame, + rect->width, + rect->height, + focused, + state.settings); + case PanelId::Waveform: + return renderWaveformPanel( + frame, + rect->width, + rect->height, + focused, + state.settings); } } @@ -1181,6 +1488,8 @@ int runInteractive(std::unique_ptr capture, pipeline.setInputTrimDb(appliedSettings.inputTrimDb); pipeline.setSpectrumTilt(appliedSettings.spectrumTiltDbPerOctave); pipeline.setOscilloscopePitchLock(appliedSettings.oscilloscopePitchLock); + applySpectrogramSettings(pipeline, appliedSettings); + applyWaveformSettings(pipeline, appliedSettings); bool captureOverrun = false; auto nextFrameAt = std::chrono::steady_clock::now(); @@ -1204,6 +1513,20 @@ int runInteractive(std::unique_ptr capture, requestedSettings.spectrumTiltDbPerOctave); pipeline.setOscilloscopePitchLock( requestedSettings.oscilloscopePitchLock); + const bool spectrogramAnalysisChanged = + requestedSettings.spectrogramClarity != appliedSettings.spectrogramClarity || + requestedSettings.spectrogramScale != appliedSettings.spectrogramScale || + requestedSettings.spectrogramOrientation != appliedSettings.spectrogramOrientation || + requestedSettings.spectrogramScrollSpeed != appliedSettings.spectrogramScrollSpeed || + requestedSettings.spectrogramContrast != appliedSettings.spectrogramContrast || + requestedSettings.spectrogramTiltDbPerOctave != appliedSettings.spectrogramTiltDbPerOctave; + if (spectrogramAnalysisChanged) { + applySpectrogramSettings(pipeline, requestedSettings); + } + if (requestedSettings.waveformMode != appliedSettings.waveformMode || + requestedSettings.waveformScrollSpeed != appliedSettings.waveformScrollSpeed) { + applyWaveformSettings(pipeline, requestedSettings); + } appliedSettings = requestedSettings; if (refreshChanged) { nextFrameAt = std::chrono::steady_clock::now(); @@ -1222,6 +1545,8 @@ int runInteractive(std::unique_ptr capture, next.lufs = analyzed.lufs; next.oscilloscope = std::move(analyzed.oscilloscope); next.vectorscope = std::move(analyzed.vectorscope); + next.spectrogram = std::move(analyzed.spectrogram); + next.waveform = std::move(analyzed.waveform); next.sampleRate = started.sampleRate; next.backend = capture->backendName(); next.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel; @@ -1230,8 +1555,8 @@ int runInteractive(std::unique_ptr capture, if (running.load() && !redrawQueued.exchange(true)) { screen.PostEvent(Event::Custom); } - nextFrameAt = now + displayFrameInterval( - appliedSettings.refreshRate); + nextFrameAt = now + + displayFrameInterval(appliedSettings.refreshRate); } std::this_thread::sleep_for(kCapturePollInterval); } @@ -1427,6 +1752,8 @@ int runInteractive(std::unique_ptr capture, if (event == Event::Character('3')) selectedPanel = PanelId::Vectorscope; if (event == Event::Character('4')) selectedPanel = PanelId::VUMeter; if (event == Event::Character('5')) selectedPanel = PanelId::LUFSMeter; + if (event == Event::Character('6')) selectedPanel = PanelId::Spectrogram; + if (event == Event::Character('7')) selectedPanel = PanelId::Waveform; if (selectedPanel) { interfaceState.focusedPanel = *selectedPanel; if (interfaceState.expandedPanel) { diff --git a/tui/src/tui_settings.cpp b/tui/src/tui_settings.cpp index 0758721..6a317c6 100644 --- a/tui/src/tui_settings.cpp +++ b/tui/src/tui_settings.cpp @@ -45,6 +45,22 @@ const std::vector kLUFSMeterSettings = { {SettingId::LUFSReadout, "Loudness readout", "Chooses which LUFS window drives the main bar and badge."}, }; +const std::vector kSpectrogramSettings = { + {SettingId::SpectrogramColor, "Color", "Switches between Prism's heat palette and a focused mono display."}, + {SettingId::SpectrogramClarity, "Clarity", "Controls frequency reassignment and spectral sharpness."}, + {SettingId::SpectrogramScale, "Frequency scale", "Chooses logarithmic, mel, or linear frequency spacing."}, + {SettingId::SpectrogramOrientation, "Orientation", "Scrolls time horizontally or vertically."}, + {SettingId::SpectrogramScrollSpeed, "Scroll speed", "Changes how quickly new time columns move through the pane."}, + {SettingId::SpectrogramContrast, "Contrast", "Shapes the intensity range without changing the analyzer input."}, + {SettingId::SpectrogramTilt, "Display tilt", "Offsets frequencies by decibels per octave around 1 kHz."}, +}; + +const std::vector kWaveformSettings = { + {SettingId::WaveformMode, "Channels", "Shows one mono envelope or separate left and right lanes."}, + {SettingId::WaveformScrollSpeed, "Scroll speed", "Changes the time resolution of the rolling waveform."}, + {SettingId::WaveformMultiband, "Multiband color", "Colors each envelope column by its dominant frequency band."}, +}; + float snap(float value, float step) { return std::round(value / step) * step; } @@ -104,6 +120,38 @@ std::string serializeLufsReadout(LUFSReadout readout) { return "short_term"; } +std::string serializeSpectrogramColor(SpectrogramColorMode mode) { + return mode == SpectrogramColorMode::Mono ? "mono" : "heat"; +} + +std::string serializeSpectrogramClarity(SpectrogramClarity clarity) { + switch (clarity) { + case SpectrogramClarity::Classic: return "classic"; + case SpectrogramClarity::Sharp: return "sharp"; + case SpectrogramClarity::Sharper: return "sharper"; + } + return "sharper"; +} + +std::string serializeSpectrogramScale(SpectrogramScale scale) { + switch (scale) { + case SpectrogramScale::Mel: return "mel"; + case SpectrogramScale::Logarithmic: return "log"; + case SpectrogramScale::Linear: return "linear"; + } + return "log"; +} + +std::string serializeSpectrogramOrientation(SpectrogramOrientation orientation) { + return orientation == SpectrogramOrientation::Vertical + ? "vertical" + : "horizontal"; +} + +std::string serializeWaveformMode(WaveformMode mode) { + return mode == WaveformMode::Stereo ? "stereo" : "mono"; +} + 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; @@ -181,6 +229,42 @@ LUFSReadout parseLufsReadout(const std::string& value, LUFSReadout fallback) { return fallback; } +SpectrogramColorMode parseSpectrogramColor( + const std::string& value, SpectrogramColorMode fallback) { + if (value == "heat") return SpectrogramColorMode::Heat; + if (value == "mono") return SpectrogramColorMode::Mono; + return fallback; +} + +SpectrogramClarity parseSpectrogramClarity( + const std::string& value, SpectrogramClarity fallback) { + if (value == "classic") return SpectrogramClarity::Classic; + if (value == "sharp") return SpectrogramClarity::Sharp; + if (value == "sharper") return SpectrogramClarity::Sharper; + return fallback; +} + +SpectrogramScale parseSpectrogramScale( + const std::string& value, SpectrogramScale fallback) { + if (value == "mel") return SpectrogramScale::Mel; + if (value == "log") return SpectrogramScale::Logarithmic; + if (value == "linear") return SpectrogramScale::Linear; + return fallback; +} + +SpectrogramOrientation parseSpectrogramOrientation( + const std::string& value, SpectrogramOrientation fallback) { + if (value == "horizontal") return SpectrogramOrientation::Horizontal; + if (value == "vertical") return SpectrogramOrientation::Vertical; + return fallback; +} + +WaveformMode parseWaveformMode(const std::string& value, WaveformMode fallback) { + if (value == "mono") return WaveformMode::Mono; + if (value == "stereo") return WaveformMode::Stereo; + return fallback; +} + const char* environmentValue(const char* name) { const char* value = std::getenv(name); return value != nullptr && value[0] != '\0' ? value : nullptr; @@ -188,6 +272,38 @@ const char* environmentValue(const char* name) { } // namespace +const char* spectrogramColorName(SpectrogramColorMode mode) { + return mode == SpectrogramColorMode::Mono ? "Mono" : "Heat"; +} + +const char* spectrogramClarityName(SpectrogramClarity clarity) { + switch (clarity) { + case SpectrogramClarity::Classic: return "Classic"; + case SpectrogramClarity::Sharp: return "Sharp"; + case SpectrogramClarity::Sharper: return "Sharper"; + } + return "Sharper"; +} + +const char* spectrogramScaleName(SpectrogramScale scale) { + switch (scale) { + case SpectrogramScale::Mel: return "Mel"; + case SpectrogramScale::Logarithmic: return "Log"; + case SpectrogramScale::Linear: return "Linear"; + } + return "Log"; +} + +const char* spectrogramOrientationName(SpectrogramOrientation orientation) { + return orientation == SpectrogramOrientation::Vertical + ? "Vertical" + : "Horizontal"; +} + +const char* waveformModeName(WaveformMode mode) { + return mode == WaveformMode::Stereo ? "Stereo" : "Mono"; +} + TuiSettings normalizeSettings(TuiSettings settings) { settings.inputTrimDb = std::clamp(snap(settings.inputTrimDb, 0.5f), -12.0f, 12.0f); settings.refreshRate = settings.refreshRate <= 30 ? 30 : 60; @@ -196,6 +312,13 @@ TuiSettings normalizeSettings(TuiSettings settings) { settings.oscilloscopeTraceWeight = std::clamp(settings.oscilloscopeTraceWeight, 1, 3); settings.vuReferenceDbfs = std::clamp( snap(settings.vuReferenceDbfs, 1.0f), -30.0f, 0.0f); + settings.spectrogramScrollSpeed = std::clamp( + snap(settings.spectrogramScrollSpeed, 0.5f), 0.5f, 4.0f); + settings.spectrogramContrast = std::clamp( + snap(settings.spectrogramContrast, 0.1f), 0.5f, 2.0f); + settings.spectrogramTiltDbPerOctave = std::clamp( + snap(settings.spectrogramTiltDbPerOctave, 0.5f), -2.0f, 8.0f); + settings.waveformScrollSpeed = std::clamp(settings.waveformScrollSpeed, 1, 8); if (!settings.oscilloscopePitchLock) { settings.oscilloscopeFrequencyReadout = false; } @@ -218,7 +341,17 @@ bool operator==(const TuiSettings& left, const TuiSettings& right) { left.vuMeterOrientation == right.vuMeterOrientation && left.vuNeedleChannels == right.vuNeedleChannels && left.vuReferenceDbfs == right.vuReferenceDbfs && - left.lufsReadout == right.lufsReadout; + left.lufsReadout == right.lufsReadout && + left.spectrogramColor == right.spectrogramColor && + left.spectrogramClarity == right.spectrogramClarity && + left.spectrogramScale == right.spectrogramScale && + left.spectrogramOrientation == right.spectrogramOrientation && + left.spectrogramScrollSpeed == right.spectrogramScrollSpeed && + left.spectrogramContrast == right.spectrogramContrast && + left.spectrogramTiltDbPerOctave == right.spectrogramTiltDbPerOctave && + left.waveformMode == right.waveformMode && + left.waveformScrollSpeed == right.waveformScrollSpeed && + left.waveformMultiband == right.waveformMultiband; } bool operator!=(const TuiSettings& left, const TuiSettings& right) { @@ -233,6 +366,8 @@ std::vector settingsPages() { SettingsPage::Vectorscope, SettingsPage::VUMeter, SettingsPage::LUFSMeter, + SettingsPage::Spectrogram, + SettingsPage::Waveform, }; } @@ -245,6 +380,8 @@ const char* settingsPageName(SettingsPage page) { case SettingsPage::Vectorscope: return "Vectorscope"; case SettingsPage::VUMeter: return "VU meter"; case SettingsPage::LUFSMeter: return "LUFS meter"; + case SettingsPage::Spectrogram: return "Spectrogram"; + case SettingsPage::Waveform: return "Waveform"; } return "Settings"; } @@ -258,6 +395,8 @@ const char* settingsPageDescription(SettingsPage page) { case SettingsPage::Vectorscope: return "Stereo projection and point rendering."; case SettingsPage::VUMeter: return "Classic level, peak, and phase metering."; case SettingsPage::LUFSMeter: return "Loudness window and target presentation."; + case SettingsPage::Spectrogram: return "Frequency history, color, and time direction."; + case SettingsPage::Waveform: return "Rolling amplitude envelope and band color."; } return {}; } @@ -270,6 +409,8 @@ const std::vector& settingsForPage(SettingsPage page) { case SettingsPage::Vectorscope: return kVectorscopeSettings; case SettingsPage::VUMeter: return kVUMeterSettings; case SettingsPage::LUFSMeter: return kLUFSMeterSettings; + case SettingsPage::Spectrogram: return kSpectrogramSettings; + case SettingsPage::Waveform: return kWaveformSettings; case SettingsPage::Home: break; } static const std::vector empty; @@ -315,6 +456,26 @@ std::string settingValue(const TuiSettings& settings, SettingId setting) { return trimFloat(settings.vuReferenceDbfs, 0) + " dBFS"; case SettingId::LUFSReadout: return lufsReadoutName(settings.lufsReadout); + case SettingId::SpectrogramColor: + return spectrogramColorName(settings.spectrogramColor); + case SettingId::SpectrogramClarity: + return spectrogramClarityName(settings.spectrogramClarity); + case SettingId::SpectrogramScale: + return spectrogramScaleName(settings.spectrogramScale); + case SettingId::SpectrogramOrientation: + return spectrogramOrientationName(settings.spectrogramOrientation); + case SettingId::SpectrogramScrollSpeed: + return trimFloat(settings.spectrogramScrollSpeed, 1) + "×"; + case SettingId::SpectrogramContrast: + return trimFloat(settings.spectrogramContrast, 1) + "×"; + case SettingId::SpectrogramTilt: + return trimFloat(settings.spectrogramTiltDbPerOctave, 1) + " dB/oct"; + case SettingId::WaveformMode: + return waveformModeName(settings.waveformMode); + case SettingId::WaveformScrollSpeed: + return std::to_string(settings.waveformScrollSpeed) + "×"; + case SettingId::WaveformMultiband: + return boolValue(settings.waveformMultiband); } return {}; } @@ -323,7 +484,8 @@ bool settingIsBoolean(SettingId setting) { return setting == SettingId::SpectrumPeakReadout || setting == SettingId::OscilloscopePitchLock || setting == SettingId::OscilloscopeFrequencyReadout || - setting == SettingId::VectorscopeGuides; + setting == SettingId::VectorscopeGuides || + setting == SettingId::WaveformMultiband; } bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) { @@ -412,6 +574,49 @@ bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) { settings.lufsReadout = static_cast(value); break; } + case SettingId::SpectrogramColor: + settings.spectrogramColor = settings.spectrogramColor == SpectrogramColorMode::Heat + ? SpectrogramColorMode::Mono + : SpectrogramColorMode::Heat; + break; + case SettingId::SpectrogramClarity: { + int value = static_cast(settings.spectrogramClarity); + value = (value + (direction > 0 ? 1 : 2)) % 3; + settings.spectrogramClarity = static_cast(value); + break; + } + case SettingId::SpectrogramScale: { + int value = static_cast(settings.spectrogramScale); + value = (value + (direction > 0 ? 1 : 2)) % 3; + settings.spectrogramScale = static_cast(value); + break; + } + case SettingId::SpectrogramOrientation: + settings.spectrogramOrientation = + settings.spectrogramOrientation == SpectrogramOrientation::Horizontal + ? SpectrogramOrientation::Vertical + : SpectrogramOrientation::Horizontal; + break; + case SettingId::SpectrogramScrollSpeed: + settings.spectrogramScrollSpeed += direction > 0 ? 0.5f : -0.5f; + break; + case SettingId::SpectrogramContrast: + settings.spectrogramContrast += direction > 0 ? 0.1f : -0.1f; + break; + case SettingId::SpectrogramTilt: + settings.spectrogramTiltDbPerOctave += direction > 0 ? 0.5f : -0.5f; + break; + case SettingId::WaveformMode: + settings.waveformMode = settings.waveformMode == WaveformMode::Mono + ? WaveformMode::Stereo + : WaveformMode::Mono; + break; + case SettingId::WaveformScrollSpeed: + settings.waveformScrollSpeed += direction > 0 ? 1 : -1; + break; + case SettingId::WaveformMultiband: + settings.waveformMultiband = !settings.waveformMultiband; + break; } settings = normalizeSettings(settings); return settings != before; @@ -437,6 +642,16 @@ bool resetSetting(TuiSettings& settings, SettingId setting) { case SettingId::VUNeedleChannels: settings.vuNeedleChannels = defaults.vuNeedleChannels; break; case SettingId::VUReferenceLevel: settings.vuReferenceDbfs = defaults.vuReferenceDbfs; break; case SettingId::LUFSReadout: settings.lufsReadout = defaults.lufsReadout; break; + case SettingId::SpectrogramColor: settings.spectrogramColor = defaults.spectrogramColor; break; + case SettingId::SpectrogramClarity: settings.spectrogramClarity = defaults.spectrogramClarity; break; + case SettingId::SpectrogramScale: settings.spectrogramScale = defaults.spectrogramScale; break; + case SettingId::SpectrogramOrientation: settings.spectrogramOrientation = defaults.spectrogramOrientation; break; + case SettingId::SpectrogramScrollSpeed: settings.spectrogramScrollSpeed = defaults.spectrogramScrollSpeed; break; + case SettingId::SpectrogramContrast: settings.spectrogramContrast = defaults.spectrogramContrast; break; + case SettingId::SpectrogramTilt: settings.spectrogramTiltDbPerOctave = defaults.spectrogramTiltDbPerOctave; break; + case SettingId::WaveformMode: settings.waveformMode = defaults.waveformMode; break; + case SettingId::WaveformScrollSpeed: settings.waveformScrollSpeed = defaults.waveformScrollSpeed; break; + case SettingId::WaveformMultiband: settings.waveformMultiband = defaults.waveformMultiband; break; } return settings != before; } @@ -487,6 +702,16 @@ TuiSettings loadSettings(const std::filesystem::path& path) { else if (key == "vu_needle_channels") settings.vuNeedleChannels = parseVuNeedleChannels(value, settings.vuNeedleChannels); else if (key == "vu_reference_dbfs") settings.vuReferenceDbfs = parseFloat(value, settings.vuReferenceDbfs); else if (key == "lufs_readout") settings.lufsReadout = parseLufsReadout(value, settings.lufsReadout); + else if (key == "spectrogram_color") settings.spectrogramColor = parseSpectrogramColor(value, settings.spectrogramColor); + else if (key == "spectrogram_clarity") settings.spectrogramClarity = parseSpectrogramClarity(value, settings.spectrogramClarity); + else if (key == "spectrogram_scale") settings.spectrogramScale = parseSpectrogramScale(value, settings.spectrogramScale); + else if (key == "spectrogram_orientation") settings.spectrogramOrientation = parseSpectrogramOrientation(value, settings.spectrogramOrientation); + else if (key == "spectrogram_scroll") settings.spectrogramScrollSpeed = parseFloat(value, settings.spectrogramScrollSpeed); + else if (key == "spectrogram_contrast") settings.spectrogramContrast = parseFloat(value, settings.spectrogramContrast); + else if (key == "spectrogram_tilt") settings.spectrogramTiltDbPerOctave = parseFloat(value, settings.spectrogramTiltDbPerOctave); + else if (key == "waveform_mode") settings.waveformMode = parseWaveformMode(value, settings.waveformMode); + else if (key == "waveform_scroll") settings.waveformScrollSpeed = parseInt(value, settings.waveformScrollSpeed); + else if (key == "waveform_multiband") settings.waveformMultiband = parseBool(value, settings.waveformMultiband); } return normalizeSettings(settings); } @@ -523,7 +748,17 @@ bool saveSettings(const TuiSettings& rawSettings, << "vu_orientation=" << serializeVuOrientation(settings.vuMeterOrientation) << '\n' << "vu_needle_channels=" << serializeVuNeedleChannels(settings.vuNeedleChannels) << '\n' << "vu_reference_dbfs=" << settings.vuReferenceDbfs << '\n' - << "lufs_readout=" << serializeLufsReadout(settings.lufsReadout) << '\n'; + << "lufs_readout=" << serializeLufsReadout(settings.lufsReadout) << '\n' + << "spectrogram_color=" << serializeSpectrogramColor(settings.spectrogramColor) << '\n' + << "spectrogram_clarity=" << serializeSpectrogramClarity(settings.spectrogramClarity) << '\n' + << "spectrogram_scale=" << serializeSpectrogramScale(settings.spectrogramScale) << '\n' + << "spectrogram_orientation=" << serializeSpectrogramOrientation(settings.spectrogramOrientation) << '\n' + << "spectrogram_scroll=" << settings.spectrogramScrollSpeed << '\n' + << "spectrogram_contrast=" << settings.spectrogramContrast << '\n' + << "spectrogram_tilt=" << settings.spectrogramTiltDbPerOctave << '\n' + << "waveform_mode=" << serializeWaveformMode(settings.waveformMode) << '\n' + << "waveform_scroll=" << settings.waveformScrollSpeed << '\n' + << "waveform_multiband=" << (settings.waveformMultiband ? "true" : "false") << '\n'; if (!output) { if (error) *error = "could not write settings file"; return false; diff --git a/tui/src/tui_settings.h b/tui/src/tui_settings.h index 35cfa03..0fcb317 100644 --- a/tui/src/tui_settings.h +++ b/tui/src/tui_settings.h @@ -18,6 +18,8 @@ enum class SettingsPage { Vectorscope, VUMeter, LUFSMeter, + Spectrogram, + Waveform, }; enum class SettingId { @@ -37,6 +39,16 @@ enum class SettingId { VUNeedleChannels, VUReferenceLevel, LUFSReadout, + SpectrogramColor, + SpectrogramClarity, + SpectrogramScale, + SpectrogramOrientation, + SpectrogramScrollSpeed, + SpectrogramContrast, + SpectrogramTilt, + WaveformMode, + WaveformScrollSpeed, + WaveformMultiband, }; enum class VectorscopeDetail { @@ -45,6 +57,12 @@ enum class VectorscopeDetail { Maximum, }; +enum class SpectrogramColorMode { Heat, Mono }; +enum class SpectrogramClarity { Classic, Sharp, Sharper }; +enum class SpectrogramScale { Mel, Logarithmic, Linear }; +enum class SpectrogramOrientation { Horizontal, Vertical }; +enum class WaveformMode { Mono, Stereo }; + struct TuiSettings { float inputTrimDb = 0.0f; int refreshRate = 60; @@ -62,6 +80,16 @@ struct TuiSettings { VUNeedleChannels vuNeedleChannels = VUNeedleChannels::Stereo; float vuReferenceDbfs = -14.0f; LUFSReadout lufsReadout = LUFSReadout::ShortTerm; + SpectrogramColorMode spectrogramColor = SpectrogramColorMode::Heat; + SpectrogramClarity spectrogramClarity = SpectrogramClarity::Sharper; + SpectrogramScale spectrogramScale = SpectrogramScale::Logarithmic; + SpectrogramOrientation spectrogramOrientation = SpectrogramOrientation::Horizontal; + float spectrogramScrollSpeed = 2.0f; + float spectrogramContrast = 1.0f; + float spectrogramTiltDbPerOctave = 4.0f; + WaveformMode waveformMode = WaveformMode::Mono; + int waveformScrollSpeed = 1; + bool waveformMultiband = false; }; struct SettingDescriptor { @@ -84,6 +112,12 @@ bool settingIsBoolean(SettingId setting); bool adjustSetting(TuiSettings& settings, SettingId setting, int direction); bool resetSetting(TuiSettings& settings, SettingId setting); +const char* spectrogramColorName(SpectrogramColorMode mode); +const char* spectrogramClarityName(SpectrogramClarity clarity); +const char* spectrogramScaleName(SpectrogramScale scale); +const char* spectrogramOrientationName(SpectrogramOrientation orientation); +const char* waveformModeName(WaveformMode mode); + std::filesystem::path defaultSettingsPath(); TuiSettings loadSettings(const std::filesystem::path& path); bool saveSettings(const TuiSettings& settings, diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index 9227ba0..6b1afac 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -4,6 +4,7 @@ #include "display_model.h" #include "meter_display_model.h" #include "scope_plot_model.h" +#include "scrolling_history.h" #include "snapshot_store.h" #include "spectrum_peak_model.h" #include "system_audio_capture.h" @@ -209,13 +210,23 @@ void testProjectionAndLayout() { Prism::Tui::PanelId::Oscilloscope, "panel focus should cycle forward"); require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) == - Prism::Tui::PanelId::LUFSMeter, + Prism::Tui::PanelId::Waveform, "panel focus should cycle backward"); const auto compactPanels = Prism::Tui::visiblePanelOrder(stacked); require(compactPanels.size() == 3 && Prism::Tui::nextPanel( Prism::Tui::PanelId::Spectrum, compactPanels) == Prism::Tui::PanelId::VUMeter, "compact layout focus should skip hidden visual scopes"); + + const auto full = Prism::Tui::buildDashboardLayout( + 140, 44, Prism::Tui::LayoutPreset::Automatic); + require(full.panels.size() == 7 && + Prism::Tui::layoutContainsPanel(full, Prism::Tui::PanelId::Spectrogram) && + Prism::Tui::layoutContainsPanel(full, Prism::Tui::PanelId::Waveform), + "large dashboards should compose all seven scopes at once"); + require(std::all_of(full.panels.begin(), full.panels.end(), [](const auto& panel) { + return panel.width > 0 && panel.height > 0; + }), "all seven panes should remain bounded after responsive layout"); } void testMeterDisplayModels() { @@ -279,7 +290,7 @@ void testSettingsModelAndPersistence() { "settings normalization should enforce public ranges"); const auto pages = Prism::Tui::settingsPages(); - require(pages.size() == 6 && + require(pages.size() == 8 && Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 3, "settings should expose shallow category pages"); Prism::Tui::TuiSettings adjusted; @@ -312,6 +323,18 @@ void testSettingsModelAndPersistence() { adjusted, Prism::Tui::SettingId::LUFSReadout, 1) && adjusted.lufsReadout == Prism::Tui::LUFSReadout::Integrated, "LUFS settings should select an independent loudness window"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::SpectrogramClarity, -1) && + adjusted.spectrogramClarity == Prism::Tui::SpectrogramClarity::Sharp, + "spectrogram settings should cycle through the native clarity modes"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::WaveformMode, 1) && + adjusted.waveformMode == Prism::Tui::WaveformMode::Stereo, + "waveform settings should expose independent mono and stereo modes"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::WaveformMultiband, 1) && + adjusted.waveformMultiband, + "waveform settings should expose the GUI multiband color mode"); require(Prism::Tui::resetSetting( adjusted, Prism::Tui::SettingId::InputTrim) && adjusted.inputTrimDb == 0.0f, @@ -480,6 +503,27 @@ void testPitchReadoutResponse() { "free-running oscilloscope windows should advance with every audio chunk"); } +void testScrollingHistory() { + Prism::Tui::ScrollingHistory history(2, 3); + history.append(std::vector{ + 1.0f, 10.0f, + 2.0f, 20.0f, + 3.0f, 30.0f, + 4.0f, 40.0f, + }); + const auto wrapped = history.snapshot(); + require(wrapped.columnCount == 3 && wrapped.columnStride == 2, + "rolling histories should remain at their fixed column capacity"); + require(wrapped.values == std::vector({ + 2.0f, 20.0f, + 3.0f, 30.0f, + 4.0f, 40.0f, + }), "rolling history snapshots should publish oldest-to-newest columns"); + history.reset(); + require(history.snapshot().values.empty(), + "history reset should remove old visual data"); +} + void testPipelineAndFakeCapture() { FakeCapture capture; Prism::Capture::StartResult started; @@ -527,6 +571,35 @@ void testPipelineAndFakeCapture() { frame.vectorscope.multibandPoints.end(), [](float sample) { return std::isfinite(sample); }), "vectorscope samples should remain finite"); + require(frame.spectrogram.display.columnCount > 0 && + frame.spectrogram.display.columnStride == Prism::Tui::kSpectrogramHistoryRows && + frame.spectrogram.heat.columnCount == frame.spectrogram.display.columnCount, + "the pipeline should publish synchronized spectrogram intensity histories"); + const size_t latestSpectrogramOffset = + (frame.spectrogram.display.columnCount - 1) * + frame.spectrogram.display.columnStride; + const auto spectrogramBegin = + frame.spectrogram.display.values.begin() + + static_cast(latestSpectrogramOffset); + const auto spectrogramEnd = spectrogramBegin + + static_cast(frame.spectrogram.display.columnStride); + const size_t dominantSpectrogramRow = static_cast(std::distance( + spectrogramBegin, std::max_element(spectrogramBegin, spectrogramEnd))); + require(dominantSpectrogramRow > 42 && dominantSpectrogramRow < 68, + "a deterministic 1 kHz tone should occupy the expected logarithmic spectrogram band"); + require(frame.waveform.history.columnCount > 0 && + frame.waveform.history.columnStride == Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE && + !frame.waveform.stereo, + "the pipeline should publish a bounded mono waveform history by default"); + const size_t latestWaveformOffset = + (frame.waveform.history.columnCount - 1) * frame.waveform.history.columnStride; + const float* latestWaveform = + frame.waveform.history.values.data() + latestWaveformOffset; + require(latestWaveform[0] < -0.20f && latestWaveform[1] > 0.20f, + "waveform columns should retain the real minimum and maximum sample envelope"); + require(std::all_of(latestWaveform + 2, latestWaveform + 5, [](float value) { + return std::isfinite(value) && value >= 0.0f; + }), "waveform columns should retain finite native multiband RMS values"); 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, @@ -548,12 +621,22 @@ void testPipelineAndFakeCapture() { "input trim should affect the real loudness analyzer before processing"); Prism::Tui::AnalysisPipeline stereoPipeline(48000.0f); + stereoPipeline.setWaveformSettings(true, 2); for (int index = 0; index < 20; ++index) { stereoPipeline.process(stereoSineChunk(1000.0f, 0.5f, 0.125f, 2400, 48000.0f)); } const auto stereo = stereoPipeline.snapshot(); require(stereo.vu.barLDb > stereo.vu.barRDb + 10.0f, "stereo VU values should preserve independent channel levels"); + require(stereo.waveform.stereo && stereo.waveform.history.columnCount > 0, + "stereo waveform mode should publish independent channel envelopes"); + const size_t stereoWaveformOffset = + (stereo.waveform.history.columnCount - 1) * + stereo.waveform.history.columnStride; + const float* stereoWaveform = + stereo.waveform.history.values.data() + stereoWaveformOffset; + require(stereoWaveform[1] > stereoWaveform[6] * 3.0f, + "stereo waveform envelopes should preserve independent channel amplitudes"); pipeline.reset(); const auto reset = pipeline.snapshot(); require(reset.lufs.integratedLUFS <= -59.0f, "reset should clear integrated loudness"); @@ -563,6 +646,11 @@ void testPipelineAndFakeCapture() { "reset should clear the fast pitch readout"); require(reset.vectorscope.pointCount == 0 && reset.vectorscope.multibandPoints.empty(), "reset should clear vectorscope history"); + require(reset.spectrogram.display.columnCount == 0 && + reset.spectrogram.heat.columnCount == 0, + "reset should clear both spectrogram histories"); + require(reset.waveform.history.columnCount == 0, + "reset should clear waveform history"); require(capture.stopped, "fake capture should stop cleanly"); } @@ -593,6 +681,7 @@ int main() { testSettingsModelAndPersistence(); testScopePlotModels(); testPitchReadoutResponse(); + testScrollingHistory(); testPipelineAndFakeCapture(); testThreadSafeSnapshots(); std::cout << "Prism TUI tests passed\n";