diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index 1de5473..8870166 100644 --- a/tui/CMakeLists.txt +++ b/tui/CMakeLists.txt @@ -33,6 +33,7 @@ add_library(prism_tui_analysis STATIC src/cli.cpp src/dashboard_layout.cpp src/display_model.cpp + src/frame_rate_meter.cpp src/meter_display_model.cpp src/output_selection.cpp src/profile_library.cpp diff --git a/tui/src/frame_rate_meter.cpp b/tui/src/frame_rate_meter.cpp new file mode 100644 index 0000000..53ce6c9 --- /dev/null +++ b/tui/src/frame_rate_meter.cpp @@ -0,0 +1,46 @@ +#include "frame_rate_meter.h" + +#include +#include + +namespace Prism::Tui { + +FrameRateMeter::FrameRateMeter(double windowSeconds) + : windowSeconds_( + std::isfinite(windowSeconds) + ? std::clamp(windowSeconds, 0.25, 5.0) + : 1.0) {} + +double FrameRateMeter::record(double timestampSeconds) { + if (!std::isfinite(timestampSeconds)) return framesPerSecond_; + if (!timestamps_.empty() && timestampSeconds <= timestamps_.back()) { + if (timestampSeconds < timestamps_.back()) reset(); + return framesPerSecond_; + } + + timestamps_.push_back(timestampSeconds); + const double cutoff = timestampSeconds - windowSeconds_; + while (timestamps_.size() > 2 && timestamps_[1] <= cutoff) { + timestamps_.pop_front(); + } + if (timestamps_.size() < 2) { + framesPerSecond_ = 0.0; + return framesPerSecond_; + } + + const double elapsed = timestamps_.back() - timestamps_.front(); + const double minimumSampleWindow = std::min(0.25, windowSeconds_); + if (elapsed < minimumSampleWindow) return framesPerSecond_; + + framesPerSecond_ = elapsed > 0.0 + ? static_cast(timestamps_.size() - 1) / elapsed + : framesPerSecond_; + return framesPerSecond_; +} + +void FrameRateMeter::reset() { + timestamps_.clear(); + framesPerSecond_ = 0.0; +} + +} // namespace Prism::Tui diff --git a/tui/src/frame_rate_meter.h b/tui/src/frame_rate_meter.h new file mode 100644 index 0000000..d5cdad3 --- /dev/null +++ b/tui/src/frame_rate_meter.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace Prism::Tui { + +class FrameRateMeter { +public: + explicit FrameRateMeter(double windowSeconds = 1.0); + + double record(double timestampSeconds); + double framesPerSecond() const { return framesPerSecond_; } + void reset(); + +private: + double windowSeconds_ = 1.0; + double framesPerSecond_ = 0.0; + std::deque timestamps_; +}; + +} // namespace Prism::Tui diff --git a/tui/src/profile_library.cpp b/tui/src/profile_library.cpp index 76a3fca..2106dc7 100644 --- a/tui/src/profile_library.cpp +++ b/tui/src/profile_library.cpp @@ -430,6 +430,8 @@ bool profileSettingsEqual(const TuiSettings& left, TuiSettings normalizedLeft = normalizeSettings(left); TuiSettings normalizedRight = normalizeSettings(right); normalizedLeft.refreshRate = normalizedRight.refreshRate; + normalizedLeft.terminalCompatibility = + normalizedRight.terminalCompatibility; return normalizedLeft == normalizedRight; } @@ -437,6 +439,8 @@ TuiSettings applyProfileSettings(const TuiSettings& profile, const TuiSettings& working) { TuiSettings applied = normalizeSettings(profile); applied.refreshRate = normalizeSettings(working).refreshRate; + applied.terminalCompatibility = + normalizeSettings(working).terminalCompatibility; return applied; } diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index bafa609..b9e7518 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -3,6 +3,7 @@ #include "analysis_pipeline.h" #include "dashboard_layout.h" #include "display_model.h" +#include "frame_rate_meter.h" #include "meter_display_model.h" #include "output_selection.h" #include "profile_library.h" @@ -200,9 +201,12 @@ struct InterfaceState { uint64_t appliedOutputSwitchSerial = 0; uint64_t outputListSerial = 0; uint64_t appliedOutputListSerial = 0; + double renderedFramesPerSecond = 0.0; }; const TuiTheme* renderTheme = nullptr; +TerminalCompatibilityMode renderTerminalCompatibility = + TerminalCompatibilityMode::Modern; const TuiTheme& palette() { static const TuiTheme fallback = defaultTuiTheme(); @@ -210,7 +214,83 @@ const TuiTheme& palette() { } ftxui::Color terminalColor(const ThemeColor& color) { - return ftxui::Color::RGB(color.red, color.green, color.blue); + using Color = ftxui::Color; + if (renderTerminalCompatibility == TerminalCompatibilityMode::Modern) { + return Color::RGB(color.red, color.green, color.blue); + } + + if (renderTerminalCompatibility == TerminalCompatibilityMode::Compatible) { + constexpr std::array levels = {0, 95, 135, 175, 215, 255}; + const auto nearestLevel = [&](int channel) { + size_t nearest = 0; + int nearestDistance = 256; + for (size_t index = 0; index < levels.size(); ++index) { + const int distance = std::abs(channel - levels[index]); + if (distance < nearestDistance) { + nearest = index; + nearestDistance = distance; + } + } + return nearest; + }; + const size_t red = nearestLevel(color.red); + const size_t green = nearestLevel(color.green); + const size_t blue = nearestLevel(color.blue); + const int cubeRed = levels[red]; + const int cubeGreen = levels[green]; + const int cubeBlue = levels[blue]; + const int cubeDistance = + (static_cast(color.red) - cubeRed) * + (static_cast(color.red) - cubeRed) + + (static_cast(color.green) - cubeGreen) * + (static_cast(color.green) - cubeGreen) + + (static_cast(color.blue) - cubeBlue) * + (static_cast(color.blue) - cubeBlue); + const int average = ( + static_cast(color.red) + color.green + color.blue) / 3; + const int grayIndex = std::clamp( + static_cast(std::lround((average - 8) / 10.0)), 0, 23); + const int gray = 8 + grayIndex * 10; + const int grayDistance = + (static_cast(color.red) - gray) * + (static_cast(color.red) - gray) + + (static_cast(color.green) - gray) * + (static_cast(color.green) - gray) + + (static_cast(color.blue) - gray) * + (static_cast(color.blue) - gray); + const int index = grayDistance < cubeDistance + ? 232 + grayIndex + : 16 + static_cast(red * 36 + green * 6 + blue); + return Color(static_cast(index)); + } + + static constexpr std::array, 16> ansi = {{ + {{0, 0, 0}}, {{205, 0, 0}}, {{0, 205, 0}}, {{205, 205, 0}}, + {{0, 0, 238}}, {{205, 0, 205}}, {{0, 205, 205}}, {{229, 229, 229}}, + {{127, 127, 127}}, {{255, 0, 0}}, {{0, 255, 0}}, {{255, 255, 0}}, + {{92, 92, 255}}, {{255, 0, 255}}, {{0, 255, 255}}, {{255, 255, 255}}, + }}; + size_t nearest = 0; + int nearestDistance = 3 * 256 * 256; + for (size_t index = 0; index < ansi.size(); ++index) { + const int red = static_cast(color.red) - ansi[index][0]; + const int green = static_cast(color.green) - ansi[index][1]; + const int blue = static_cast(color.blue) - ansi[index][2]; + const int distance = red * red + green * green + blue * blue; + if (distance < nearestDistance) { + nearest = index; + nearestDistance = distance; + } + } + return Color(static_cast(nearest)); +} + +ftxui::Color terminalRgb(int red, int green, int blue) { + return terminalColor({ + static_cast(std::clamp(red, 0, 255)), + static_cast(std::clamp(green, 0, 255)), + static_cast(std::clamp(blue, 0, 255)), + }); } void fillCanvasBackground(ftxui::Canvas& surface, @@ -268,6 +348,7 @@ const size_t& settingsSelection(const InterfaceState& state) { std::string makeCaptureStatus(const DisplayFrame& frame, const TuiSettings& settings, + double renderedFramesPerSecond, bool compact) { std::ostringstream sampleRate; const double kilohertz = frame.sampleRate / 1000.0; @@ -287,6 +368,19 @@ std::string makeCaptureStatus(const DisplayFrame& frame, if (frame.captureOverrun) { footer += " • capture overrun"; } + if (!compact) { + const int target = effectiveRefreshRate( + settings.refreshRate, settings.terminalCompatibility); + footer += " • "; + if (renderedFramesPerSecond > 0.0 && + std::isfinite(renderedFramesPerSecond)) { + footer += std::to_string(static_cast( + std::lround(renderedFramesPerSecond))); + } else { + footer += "--"; + } + footer += "/" + std::to_string(target) + " FPS"; + } return footer; } @@ -1185,7 +1279,7 @@ ftxui::Color interpolateColor(const ThemeColor& from, const ThemeColor& to, float amount) { const float t = std::clamp(amount, 0.0f, 1.0f); - return ftxui::Color::RGB( + return terminalRgb( static_cast(std::lround(from.red + (static_cast(to.red) - from.red) * t)), static_cast(std::lround(from.green + @@ -1448,7 +1542,7 @@ ftxui::Color waveformBandColor(const float* summary, bool multiband) { weights[band] * static_cast(value))); } } - return ftxui::Color::RGB( + return terminalRgb( std::clamp(mixed[0], 0, 255), std::clamp(mixed[1], 0, 255), std::clamp(mixed[2], 0, 255)); @@ -1706,7 +1800,11 @@ ftxui::Element renderFooter(const DisplayFrame& frame, " • o outputs • p profiles • s settings • q quit" : "Tab focus • Enter " + enterAction + " • o outputs • p profiles • s settings • l edit layout • r reset • q quit"; - auto status = text(makeCaptureStatus(frame, state.settings, compact)) | dim; + auto status = text(makeCaptureStatus( + frame, + state.settings, + state.renderedFramesPerSecond, + compact)) | dim; if (frame.captureOverrun) { status = status | color(terminalColor(palette().danger)); } @@ -2395,7 +2493,12 @@ int runInteractive(std::unique_ptr capture, const TuiSettings requestedSettings = settingsStore.read(); if (requestedSettings != appliedSettings) { const bool refreshChanged = - requestedSettings.refreshRate != appliedSettings.refreshRate; + effectiveRefreshRate( + requestedSettings.refreshRate, + requestedSettings.terminalCompatibility) != + effectiveRefreshRate( + appliedSettings.refreshRate, + appliedSettings.terminalCompatibility); pipeline->setInputTrimDb(requestedSettings.inputTrimDb); pipeline->setSpectrumTilt( requestedSettings.spectrumTiltDbPerOctave); @@ -2446,7 +2549,9 @@ int runInteractive(std::unique_ptr capture, screen.PostEvent(Event::Custom); } nextFrameAt = now + - displayFrameInterval(appliedSettings.refreshRate); + displayFrameInterval(effectiveRefreshRate( + appliedSettings.refreshRate, + appliedSettings.terminalCompatibility)); } std::this_thread::sleep_for(kCapturePollInterval); } @@ -2458,8 +2563,15 @@ int runInteractive(std::unique_ptr capture, } }); + FrameRateMeter frameRateMeter; auto renderer = Renderer([&]() { renderTheme = &interfaceState.theme; + renderTerminalCompatibility = + interfaceState.settings.terminalCompatibility; + const double renderedAt = std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()).count(); + interfaceState.renderedFramesPerSecond = + frameRateMeter.record(renderedAt); return renderFrame( frameStore.read(), screen.dimx(), screen.dimy(), interfaceState) | color(terminalColor(interfaceState.theme.text)) | diff --git a/tui/src/tui_settings.cpp b/tui/src/tui_settings.cpp index f0a77c2..a072bb6 100644 --- a/tui/src/tui_settings.cpp +++ b/tui/src/tui_settings.cpp @@ -15,6 +15,7 @@ namespace { const std::vector kAppearanceSettings = { {SettingId::Theme, "Theme", "Uses Prism .iro files from the shared Prism Themes folder."}, + {SettingId::TerminalCompatibility, "Terminal mode", "Modern uses truecolor; Compatible uses 256 colors at up to 60 FPS; Safe uses ANSI colors at 30 FPS."}, }; const std::vector kGeneralSettings = { @@ -153,6 +154,15 @@ std::string serializeWaveformMode(WaveformMode mode) { return mode == WaveformMode::Stereo ? "stereo" : "mono"; } +std::string serializeTerminalCompatibility(TerminalCompatibilityMode mode) { + switch (mode) { + case TerminalCompatibilityMode::Modern: return "modern"; + case TerminalCompatibilityMode::Compatible: return "compatible"; + case TerminalCompatibilityMode::Safe: return "safe"; + } + return "modern"; +} + 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; @@ -259,6 +269,15 @@ WaveformMode parseWaveformMode(const std::string& value, WaveformMode fallback) return fallback; } +TerminalCompatibilityMode parseTerminalCompatibility( + const std::string& value, + TerminalCompatibilityMode fallback) { + if (value == "modern") return TerminalCompatibilityMode::Modern; + if (value == "compatible") return TerminalCompatibilityMode::Compatible; + if (value == "safe") return TerminalCompatibilityMode::Safe; + return fallback; +} + const char* environmentValue(const char* name) { const char* value = std::getenv(name); return value != nullptr && value[0] != '\0' ? value : nullptr; @@ -298,6 +317,30 @@ const char* waveformModeName(WaveformMode mode) { return mode == WaveformMode::Stereo ? "Stereo" : "Mono"; } +const char* terminalCompatibilityName(TerminalCompatibilityMode mode) { + switch (mode) { + case TerminalCompatibilityMode::Modern: return "Modern"; + case TerminalCompatibilityMode::Compatible: return "Compatible"; + case TerminalCompatibilityMode::Safe: return "Safe"; + } + return "Modern"; +} + +int effectiveRefreshRate(int requestedRate, TerminalCompatibilityMode mode) { + const int normalized = requestedRate <= 30 + ? 30 + : requestedRate <= 60 ? 60 : 120; + switch (mode) { + case TerminalCompatibilityMode::Modern: + return normalized; + case TerminalCompatibilityMode::Compatible: + return std::min(normalized, 60); + case TerminalCompatibilityMode::Safe: + return 30; + } + return normalized; +} + TuiSettings normalizeSettings(TuiSettings settings) { settings.themeId.erase(std::remove_if( settings.themeId.begin(), settings.themeId.end(), [](char character) { @@ -329,6 +372,7 @@ TuiSettings normalizeSettings(TuiSettings settings) { bool operator==(const TuiSettings& left, const TuiSettings& right) { return left.themeId == right.themeId && + left.terminalCompatibility == right.terminalCompatibility && left.inputTrimDb == right.inputTrimDb && left.refreshRate == right.refreshRate && left.rackLayout == right.rackLayout && @@ -428,6 +472,8 @@ std::string settingValue(const TuiSettings& settings, SettingId setting) { switch (setting) { case SettingId::Theme: return settings.themeId; + case SettingId::TerminalCompatibility: + return terminalCompatibilityName(settings.terminalCompatibility); case SettingId::InputTrim: return (settings.inputTrimDb > 0.0f ? "+" : "") + trimFloat(settings.inputTrimDb, 1) + " dB"; @@ -502,6 +548,13 @@ bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) { switch (setting) { case SettingId::Theme: return false; + case SettingId::TerminalCompatibility: { + int value = static_cast(settings.terminalCompatibility); + value = (value + (direction > 0 ? 1 : 2)) % 3; + settings.terminalCompatibility = + static_cast(value); + break; + } case SettingId::InputTrim: settings.inputTrimDb += direction > 0 ? 0.5f : -0.5f; break; @@ -634,6 +687,7 @@ bool resetSetting(TuiSettings& settings, SettingId setting) { const TuiSettings before = settings; switch (setting) { case SettingId::Theme: settings.themeId = defaults.themeId; break; + case SettingId::TerminalCompatibility: settings.terminalCompatibility = defaults.terminalCompatibility; break; case SettingId::InputTrim: settings.inputTrimDb = defaults.inputTrimDb; break; case SettingId::RefreshRate: settings.refreshRate = defaults.refreshRate; break; case SettingId::SpectrumPeakReadout: settings.spectrumPeakReadout = defaults.spectrumPeakReadout; break; @@ -704,6 +758,7 @@ TuiSettings parseSettingsText(const std::string& text, const std::string key = line.substr(0, separator); const std::string value = line.substr(separator + 1); if (key == "theme_id") settings.themeId = value; + else if (key == "terminal_mode") settings.terminalCompatibility = parseTerminalCompatibility(value, settings.terminalCompatibility); else 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 == "rack_layout") settings.rackLayout = parseRackLayout(value, settings.rackLayout); @@ -738,12 +793,14 @@ std::string serializeSettingsText(const TuiSettings& rawSettings, bool includeRefreshRate) { const TuiSettings settings = normalizeSettings(rawSettings); std::ostringstream output; - output << "theme_id=" << settings.themeId << '\n' - << "input_trim_db=" << settings.inputTrimDb << '\n'; + output << "theme_id=" << settings.themeId << '\n'; if (includeRefreshRate) { - output << "refresh_rate=" << settings.refreshRate << '\n'; + output << "terminal_mode=" + << serializeTerminalCompatibility(settings.terminalCompatibility) << '\n' + << "refresh_rate=" << settings.refreshRate << '\n'; } - output << "rack_layout=" << serializeRackLayout(settings.rackLayout) << '\n' + output << "input_trim_db=" << settings.inputTrimDb << '\n' + << "rack_layout=" << serializeRackLayout(settings.rackLayout) << '\n' << "spectrum_peak=" << (settings.spectrumPeakReadout ? "true" : "false") << '\n' << "spectrum_tilt=" << settings.spectrumTiltDbPerOctave << '\n' << "osc_pitch_lock=" << (settings.oscilloscopePitchLock ? "true" : "false") << '\n' diff --git a/tui/src/tui_settings.h b/tui/src/tui_settings.h index 87cc34a..cfc920e 100644 --- a/tui/src/tui_settings.h +++ b/tui/src/tui_settings.h @@ -25,6 +25,7 @@ enum class SettingsPage { enum class SettingId { Theme, + TerminalCompatibility, InputTrim, RefreshRate, SpectrumPeakReadout, @@ -63,9 +64,12 @@ enum class SpectrogramClarity { Classic, Sharp, Sharper }; enum class SpectrogramScale { Mel, Logarithmic, Linear }; enum class SpectrogramOrientation { Horizontal, Vertical }; enum class WaveformMode { Mono, Stereo }; +enum class TerminalCompatibilityMode { Modern, Compatible, Safe }; struct TuiSettings { std::string themeId = "Default"; + TerminalCompatibilityMode terminalCompatibility = + TerminalCompatibilityMode::Modern; float inputTrimDb = 0.0f; int refreshRate = 60; RackLayout rackLayout = defaultRackLayout(); @@ -119,6 +123,8 @@ const char* spectrogramClarityName(SpectrogramClarity clarity); const char* spectrogramScaleName(SpectrogramScale scale); const char* spectrogramOrientationName(SpectrogramOrientation orientation); const char* waveformModeName(WaveformMode mode); +const char* terminalCompatibilityName(TerminalCompatibilityMode mode); +int effectiveRefreshRate(int requestedRate, TerminalCompatibilityMode mode); std::filesystem::path defaultSettingsPath(); TuiSettings parseSettingsText(const std::string& text, diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index a9b621e..0538b70 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -2,6 +2,7 @@ #include "cli.h" #include "dashboard_layout.h" #include "display_model.h" +#include "frame_rate_meter.h" #include "meter_display_model.h" #include "output_selection.h" #include "profile_library.h" @@ -549,10 +550,29 @@ void testSettingsModelAndPersistence() { const auto pages = Prism::Tui::settingsPages(); require(pages.size() == 9 && - Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::Appearance).size() == 1 && + Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::Appearance).size() == 2 && Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 2, "settings should expose shallow category pages"); Prism::Tui::TuiSettings adjusted; + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::TerminalCompatibility, 1) && + adjusted.terminalCompatibility == + Prism::Tui::TerminalCompatibilityMode::Compatible && + Prism::Tui::effectiveRefreshRate( + 120, adjusted.terminalCompatibility) == 60, + "compatible terminals should use the 256-color mode with a 60 FPS cap"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::TerminalCompatibility, 1) && + adjusted.terminalCompatibility == + Prism::Tui::TerminalCompatibilityMode::Safe && + Prism::Tui::effectiveRefreshRate( + 120, adjusted.terminalCompatibility) == 30, + "safe terminals should use the ANSI mode with a 30 FPS cap"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::TerminalCompatibility, 1) && + adjusted.terminalCompatibility == + Prism::Tui::TerminalCompatibilityMode::Modern, + "terminal compatibility should cycle back to modern truecolor"); require(Prism::Tui::adjustSetting( adjusted, Prism::Tui::SettingId::RefreshRate, 1) && adjusted.refreshRate == 120 && @@ -623,6 +643,8 @@ void testSettingsModelAndPersistence() { adjusted.vectorscopeMode = Prism::Tui::VectorscopeMode::PolarBipolar; adjusted.vectorscopeDetail = Prism::Tui::VectorscopeDetail::Maximum; adjusted.themeId = "Test Theme"; + adjusted.terminalCompatibility = + Prism::Tui::TerminalCompatibilityMode::Compatible; std::string error; require(Prism::Tui::saveSettings(adjusted, settingsPath, &error), "settings should persist to a TUI-specific configuration file"); @@ -650,6 +672,8 @@ void testProfileLibrary() { Prism::Tui::TuiSettings settings; settings.themeId = "Test Theme"; + settings.terminalCompatibility = + Prism::Tui::TerminalCompatibilityMode::Safe; settings.inputTrimDb = 4.0f; settings.refreshRate = 30; settings.spectrogramContrast = 1.7f; @@ -676,20 +700,26 @@ void testProfileLibrary() { foundProfileFile = true; require(text.find("format=prism-tui-profile") != std::string::npos && text.find("theme_id=Test Theme") != std::string::npos && - text.find("refresh_rate=") == std::string::npos, - ".prsmt files should retain themes while excluding global refresh settings"); + text.find("refresh_rate=") == std::string::npos && + text.find("terminal_mode=") == std::string::npos, + ".prsmt files should retain themes while excluding terminal runtime settings"); } require(foundProfileFile, "saved TUI profiles should use the .prsmt extension"); Prism::Tui::TuiSettings differentRefresh = settings; differentRefresh.refreshRate = 60; + differentRefresh.terminalCompatibility = + Prism::Tui::TerminalCompatibilityMode::Modern; require(Prism::Tui::profileSettingsEqual(settings, differentRefresh), - "refresh rate should not make an active profile dirty"); + "terminal runtime settings should not make an active profile dirty"); const auto applied = Prism::Tui::applyProfileSettings( settings, differentRefresh); - require(applied.refreshRate == 60 && applied.inputTrimDb == 4.0f, - "loading a profile should preserve global refresh rate while applying trim"); + require(applied.refreshRate == 60 && + applied.terminalCompatibility == + Prism::Tui::TerminalCompatibilityMode::Modern && + applied.inputTrimDb == 4.0f, + "loading a profile should preserve terminal runtime settings while applying trim"); settings.waveformMultiband = true; require(library.overwrite(profileId, settings, &error) && @@ -1032,6 +1062,23 @@ void testThreadSafeSnapshots() { "immutable display snapshots should publish safely across threads"); } +void testFrameRateMeter() { + Prism::Tui::FrameRateMeter meter; + require(meter.record(0.0) == 0.0 && meter.record(0.01) == 0.0, + "render telemetry should wait for a stable sample window"); + meter.reset(); + for (int frame = 0; frame <= 60; ++frame) { + meter.record(static_cast(frame) / 60.0); + } + require(std::abs(meter.framesPerSecond() - 60.0) < 0.1, + "render telemetry should measure a deterministic 60 FPS cadence"); + meter.reset(); + require(meter.framesPerSecond() == 0.0 && meter.record(5.0) == 0.0, + "render telemetry should reset without inventing an initial frame rate"); + require(meter.record(4.0) == 0.0, + "render telemetry should recover safely from a regressing clock"); +} + } // namespace int main() { @@ -1047,6 +1094,7 @@ int main() { testPitchReadoutResponse(); testScrollingHistory(); testPipelineAndFakeCapture(); + testFrameRateMeter(); testThreadSafeSnapshots(); std::cout << "Prism TUI tests passed\n"; return 0;