From 5910005dd420b30be32516fc7c359480dc8c114a Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:19:53 -0400 Subject: [PATCH] add vu and lufs --- tui/CMakeLists.txt | 1 + tui/src/cli.cpp | 2 +- tui/src/dashboard_layout.cpp | 27 +- tui/src/dashboard_layout.h | 3 +- tui/src/meter_display_model.cpp | 104 +++++++ tui/src/meter_display_model.h | 41 +++ tui/src/tui_runtime.cpp | 489 +++++++++++++++++++++++++++++--- tui/src/tui_settings.cpp | 129 ++++++++- tui/src/tui_settings.h | 13 + tui/test/tui_tests.cpp | 63 +++- 10 files changed, 804 insertions(+), 68 deletions(-) create mode 100644 tui/src/meter_display_model.cpp create mode 100644 tui/src/meter_display_model.h diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index 2bca4ae..3c6b7a3 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/meter_display_model.cpp src/scope_plot_model.cpp src/spectrum_peak_model.cpp src/tui_settings.cpp diff --git a/tui/src/cli.cpp b/tui/src/cli.cpp index da40a1d..9c1f094 100644 --- a/tui/src/cli.cpp +++ b/tui/src/cli.cpp @@ -63,7 +63,7 @@ std::string usageText() { " s Open settings for the focused scope.\n" " l Cycle automatic, stacked, and column layouts.\n" " v Cycle vectorscope display modes.\n" - " 1 / 2 / 3 / 4 Focus Spectrum, Oscilloscope, Vectorscope, or Levels.\n" + " 1 / 2 / 3 / 4 / 5 Focus Spectrum, Oscilloscope, Vectorscope, VU, or LUFS.\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 da8076c..538e7f0 100644 --- a/tui/src/dashboard_layout.cpp +++ b/tui/src/dashboard_layout.cpp @@ -20,8 +20,10 @@ MinimumSize panelMinimumSize(PanelId panel) { return {30, 5}; case PanelId::Vectorscope: return {30, 8}; - case PanelId::Levels: + case PanelId::VUMeter: return {30, 5}; + case PanelId::LUFSMeter: + return {30, 7}; } return {1, 1}; } @@ -151,7 +153,7 @@ LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) { : LayoutPreset::Stacked; } -LayoutNode makeRoot(LayoutPreset preset) { +LayoutNode makeRoot(LayoutPreset preset, int width, int height) { if (preset == LayoutPreset::Columns) { return LayoutNode::split(SplitAxis::Columns, { LayoutNode::split(SplitAxis::Rows, { @@ -159,14 +161,24 @@ LayoutNode makeRoot(LayoutPreset preset) { LayoutNode::leaf(PanelId::Oscilloscope, 2), }, 3), LayoutNode::split(SplitAxis::Rows, { - LayoutNode::leaf(PanelId::Vectorscope, 1), - LayoutNode::leaf(PanelId::Levels, 1), + LayoutNode::leaf(PanelId::Vectorscope, 2), + LayoutNode::leaf(PanelId::VUMeter, 1), + LayoutNode::leaf(PanelId::LUFSMeter, 1), }, 1), }); } + if (width >= 60 && height >= 14) { + return LayoutNode::split(SplitAxis::Rows, { + LayoutNode::leaf(PanelId::Spectrum, 3), + LayoutNode::split(SplitAxis::Columns, { + LayoutNode::leaf(PanelId::VUMeter), + LayoutNode::leaf(PanelId::LUFSMeter), + }, 2), + }); + } return LayoutNode::split(SplitAxis::Rows, { LayoutNode::leaf(PanelId::Spectrum, 4), - LayoutNode::leaf(PanelId::Levels, 1), + LayoutNode::leaf(PanelId::VUMeter, 1), }); } @@ -203,7 +215,7 @@ DashboardLayout buildDashboardLayout(int width, layout.resolvedPreset = resolvePreset(requestedPreset, width, height); layout.root = expandedPanel ? LayoutNode::leaf(*expandedPanel) - : makeRoot(layout.resolvedPreset); + : makeRoot(layout.resolvedPreset, width, height); // The header and footer each consume one terminal row. resolveNode(layout.root, 0, 0, width, height - 2, layout.panels); @@ -239,7 +251,8 @@ std::vector panelOrder() { PanelId::Spectrum, PanelId::Oscilloscope, PanelId::Vectorscope, - PanelId::Levels, + PanelId::VUMeter, + PanelId::LUFSMeter, }; } diff --git a/tui/src/dashboard_layout.h b/tui/src/dashboard_layout.h index 24712f9..f1cf61f 100644 --- a/tui/src/dashboard_layout.h +++ b/tui/src/dashboard_layout.h @@ -10,7 +10,8 @@ enum class PanelId { Spectrum, Oscilloscope, Vectorscope, - Levels, + VUMeter, + LUFSMeter, }; enum class SplitAxis { diff --git a/tui/src/meter_display_model.cpp b/tui/src/meter_display_model.cpp new file mode 100644 index 0000000..25a57b1 --- /dev/null +++ b/tui/src/meter_display_model.cpp @@ -0,0 +1,104 @@ +#include "meter_display_model.h" + +#include +#include +#include + +namespace Prism::Tui { +namespace { + +struct VuAnchor { + float vu; + float normalized; +}; + +constexpr std::array kVuAnchors = {{ + {-20.0f, 0.00f}, + {-10.0f, 0.24f}, + {-7.0f, 0.36f}, + {-5.0f, 0.46f}, + {-4.0f, 0.53f}, + {-3.0f, 0.60f}, + {-2.0f, 0.67f}, + {-1.0f, 0.74f}, + {0.0f, 0.81f}, + {1.0f, 0.88f}, + {2.0f, 0.94f}, + {3.0f, 1.00f}, +}}; + +} // namespace + +float dbfsToClassicVu(float dbfs, float referenceDbfs) { + if (!std::isfinite(dbfs)) return -20.0f; + return std::clamp(dbfs - referenceDbfs, -20.0f, 3.0f); +} + +float classicVuToNormalized(float vu) { + const float clamped = std::clamp(vu, -20.0f, 3.0f); + for (size_t index = 1; index < kVuAnchors.size(); ++index) { + const auto& left = kVuAnchors[index - 1]; + const auto& right = kVuAnchors[index]; + if (clamped <= right.vu) { + const float amount = (clamped - left.vu) / (right.vu - left.vu); + return left.normalized + amount * (right.normalized - left.normalized); + } + } + return 1.0f; +} + +float vuDbToNormalized(float dbfs, float referenceDbfs) { + return classicVuToNormalized(dbfsToClassicVu(dbfs, referenceDbfs)); +} + +float compactMeterToNormalized(float db) { + if (!std::isfinite(db)) return 0.0f; + return std::clamp((db + 50.0f) / 50.0f, 0.0f, 1.0f); +} + +float stereoRmsDbAverage(float leftDb, float rightDb) { + if (!std::isfinite(leftDb) && !std::isfinite(rightDb)) return -60.0f; + const float leftPower = std::isfinite(leftDb) + ? std::pow(10.0f, leftDb / 10.0f) + : 0.0f; + const float rightPower = std::isfinite(rightDb) + ? std::pow(10.0f, rightDb / 10.0f) + : 0.0f; + const float meanPower = (leftPower + rightPower) * 0.5f; + return meanPower > 0.0f ? 10.0f * std::log10(meanPower) : -60.0f; +} + +float selectLufsReadout(float momentary, + float shortTerm, + float integrated, + LUFSReadout readout) { + switch (readout) { + case LUFSReadout::Momentary: return momentary; + case LUFSReadout::ShortTerm: return shortTerm; + case LUFSReadout::Integrated: return integrated; + } + return shortTerm; +} + +const char* vuMeterModeName(VUMeterMode mode) { + return mode == VUMeterMode::Needle ? "Needle" : "Bar"; +} + +const char* vuMeterOrientationName(VUMeterOrientation orientation) { + return orientation == VUMeterOrientation::Vertical ? "Vertical" : "Horizontal"; +} + +const char* vuNeedleChannelsName(VUNeedleChannels channels) { + return channels == VUNeedleChannels::Combined ? "Combined" : "Stereo"; +} + +const char* lufsReadoutName(LUFSReadout readout) { + switch (readout) { + case LUFSReadout::Momentary: return "Momentary"; + case LUFSReadout::ShortTerm: return "Short term"; + case LUFSReadout::Integrated: return "Integrated"; + } + return "Short term"; +} + +} // namespace Prism::Tui diff --git a/tui/src/meter_display_model.h b/tui/src/meter_display_model.h new file mode 100644 index 0000000..7814433 --- /dev/null +++ b/tui/src/meter_display_model.h @@ -0,0 +1,41 @@ +#pragma once + +namespace Prism::Tui { + +enum class VUMeterMode { + Bar, + Needle, +}; + +enum class VUMeterOrientation { + Horizontal, + Vertical, +}; + +enum class VUNeedleChannels { + Stereo, + Combined, +}; + +enum class LUFSReadout { + Momentary, + ShortTerm, + Integrated, +}; + +float dbfsToClassicVu(float dbfs, float referenceDbfs); +float classicVuToNormalized(float vu); +float vuDbToNormalized(float dbfs, float referenceDbfs); +float compactMeterToNormalized(float db); +float stereoRmsDbAverage(float leftDb, float rightDb); +float selectLufsReadout(float momentary, + float shortTerm, + float integrated, + LUFSReadout readout); + +const char* vuMeterModeName(VUMeterMode mode); +const char* vuMeterOrientationName(VUMeterOrientation orientation); +const char* vuNeedleChannelsName(VUNeedleChannels channels); +const char* lufsReadoutName(LUFSReadout readout); + +} // namespace Prism::Tui diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index 0b17cdc..78543c8 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 "meter_display_model.h" #include "scope_plot_model.h" #include "snapshot_store.h" #include "tui_settings.h" @@ -89,7 +90,7 @@ struct InterfaceState { bool settingsOpen = false; SettingsPage settingsPage = SettingsPage::Home; size_t settingsHomeSelection = 0; - std::array settingsSelections{}; + std::array settingsSelections{}; std::string settingsStatus; }; @@ -152,8 +153,10 @@ std::string panelName(PanelId panel) { return "Oscilloscope"; case PanelId::Vectorscope: return "Vectorscope"; - case PanelId::Levels: - return "Levels"; + case PanelId::VUMeter: + return "VU Meter"; + case PanelId::LUFSMeter: + return "LUFS Meter"; } return "Panel"; } @@ -166,8 +169,10 @@ std::string panelNumber(PanelId panel) { return "2"; case PanelId::Vectorscope: return "3"; - case PanelId::Levels: + case PanelId::VUMeter: return "4"; + case PanelId::LUFSMeter: + return "5"; } return "?"; } @@ -468,52 +473,435 @@ ftxui::Element renderVectorscopePanel(const DisplayFrame& frame, size(HEIGHT, EQUAL, std::max(1, height)); } -ftxui::Element renderLevelsPanel(const DisplayFrame& frame, - int width, - int height, - bool focused) { +std::string formatVuReference(float referenceDbfs) { + std::ostringstream output; + output << std::fixed << std::setprecision(0) << referenceDbfs << " dBFS"; + return output.str(); +} + +ftxui::Element renderClassicVuGauge(float levelDb, + float peakDb, + float referenceDbfs, + int columns) { + using namespace ftxui; + const int resolvedColumns = std::max(1, columns); + const int levelColumns = std::clamp( + static_cast(std::lround( + vuDbToNormalized(levelDb, referenceDbfs) * resolvedColumns)), + 0, + resolvedColumns); + const int peakColumn = std::clamp( + static_cast(std::lround( + vuDbToNormalized(peakDb, referenceDbfs) * (resolvedColumns - 1))), + 0, + resolvedColumns - 1); + const int hotColumn = static_cast(std::lround( + classicVuToNormalized(0.0f) * resolvedColumns)); + const bool showPeak = std::isfinite(peakDb) && peakDb > -59.0f; + + Elements cells; + cells.reserve(static_cast(resolvedColumns)); + for (int column = 0; column < resolvedColumns; ++column) { + if (showPeak && column == peakColumn) { + const bool hot = dbfsToClassicVu(peakDb, referenceDbfs) > 0.0f; + cells.push_back(text("│") | color( + hot ? Color::RedLight : Color::CyanLight)); + } else if (column < levelColumns) { + cells.push_back(text("█") | color( + column >= hotColumn ? Color::Red : Color::Cyan)); + } else { + cells.push_back(text("·") | color(Color::GrayDark)); + } + } + return hbox(std::move(cells)) | size(WIDTH, EQUAL, resolvedColumns); +} + +ftxui::Element renderCorrelationGauge(float correlation, int columns) { + using namespace ftxui; + const int resolvedColumns = std::max(5, columns); + const int center = resolvedColumns / 2; + const float clamped = std::clamp(correlation, -1.0f, 1.0f); + const int extent = static_cast(std::lround( + std::abs(clamped) * static_cast(center))); + Elements cells; + cells.reserve(static_cast(resolvedColumns)); + for (int column = 0; column < resolvedColumns; ++column) { + const bool positiveFill = clamped >= 0.0f && + column >= center && column < center + extent; + const bool negativeFill = clamped < 0.0f && + column < center && column >= center - extent; + if (column == center) { + cells.push_back(text("│") | color(Color::GrayLight)); + } else if (positiveFill) { + cells.push_back(text("█") | color(Color::Cyan)); + } else if (negativeFill) { + cells.push_back(text("█") | color(Color::Red)); + } else { + cells.push_back(text("·") | color(Color::GrayDark)); + } + } + return hbox({ + text("-1 ") | dim, + hbox(std::move(cells)) | size(WIDTH, EQUAL, resolvedColumns), + text(" +1") | dim, + }); +} + +std::string buildClassicVuScale(int columns) { + std::string result(static_cast(std::max(1, columns)), ' '); + const auto place = [&](float vu, const std::string& label) { + if (label.size() > result.size()) return; + const size_t position = static_cast(std::lround( + classicVuToNormalized(vu) * static_cast(result.size() - 1))); + const size_t start = std::min( + result.size() - label.size(), + position > label.size() / 2 ? position - label.size() / 2 : size_t{0}); + result.replace(start, label.size(), label); + }; + place(-20.0f, "-20"); + place(-10.0f, "-10"); + place(-5.0f, "-5"); + place(0.0f, "0"); + place(3.0f, "+3"); + return result; +} + +ftxui::Element renderHorizontalVu(const DisplayFrame& frame, + int contentWidth, + int contentHeight, + const TuiSettings& settings) { + using namespace ftxui; + const int gaugeWidth = std::max(5, contentWidth - 13); + const auto channel = [&](const char* label, float level, float peak) { + return hbox({ + text(std::string(label) + " ") | bold, + renderClassicVuGauge(level, peak, settings.vuReferenceDbfs, gaugeWidth), + text(" " + formatDb(level) + " dB") | dim, + }); + }; + Elements body; + body.push_back(filler()); + body.push_back(channel("L", frame.vu.vuLDb, frame.vu.peakLDb)); + body.push_back(channel("R", frame.vu.vuRDb, frame.vu.peakRDb)); + if (contentHeight >= 6) { + body.push_back(hbox({ + text(" "), + text(buildClassicVuScale(gaugeWidth)) | dim, + })); + } + body.push_back(renderCorrelationGauge( + frame.vu.correlation, std::max(5, contentWidth - 8)) | center); + body.push_back(filler()); + return vbox(std::move(body)); +} + +ftxui::Element renderVerticalVuBar(float levelDb, + float peakDb, + float referenceDbfs, + int row, + int rows) { + using namespace ftxui; + const float level = vuDbToNormalized(levelDb, referenceDbfs); + const float peak = vuDbToNormalized(peakDb, referenceDbfs); + const float top = 1.0f - static_cast(row) / static_cast(rows); + const float bottom = 1.0f - static_cast(row + 1) / static_cast(rows); + const bool peakHere = peak > bottom && peak <= top; + const bool filled = level > bottom; + const bool hot = bottom >= classicVuToNormalized(0.0f); + if (peakHere) return text("━━") | color(hot ? Color::RedLight : Color::CyanLight); + if (filled) return text("██") | color(hot ? Color::Red : Color::Cyan); + return text("··") | color(Color::GrayDark); +} + +ftxui::Element renderVerticalVu(const DisplayFrame& frame, + int contentWidth, + int contentHeight, + const TuiSettings& settings) { + using namespace ftxui; + const int meterRows = std::max(1, contentHeight - 3); + Elements rows; + rows.push_back(text("L R") | center | bold); + for (int row = 0; row < meterRows; ++row) { + rows.push_back(hbox({ + renderVerticalVuBar( + frame.vu.vuLDb, frame.vu.peakLDb, + settings.vuReferenceDbfs, row, meterRows), + text(" "), + renderVerticalVuBar( + frame.vu.vuRDb, frame.vu.peakRDb, + settings.vuReferenceDbfs, row, meterRows), + }) | center); + } + rows.push_back(text( + formatDb(frame.vu.vuLDb) + " " + formatDb(frame.vu.vuRDb) + " dB") | + center | dim); + rows.push_back(renderCorrelationGauge( + frame.vu.correlation, std::max(5, contentWidth - 8)) | center); + return vbox(std::move(rows)); +} + +ftxui::Element renderNeedleVu(const DisplayFrame& frame, + int contentWidth, + int contentHeight, + const TuiSettings& settings) { + using namespace ftxui; + const bool combined = settings.vuNeedleChannels == VUNeedleChannels::Combined; + const float combinedDb = stereoRmsDbAverage(frame.vu.vuLDb, frame.vu.vuRDb); + const float combinedPeak = std::max(frame.vu.peakLDb, frame.vu.peakRDb); + const float referenceDbfs = settings.vuReferenceDbfs; + const int plotRows = std::max(1, contentHeight - 2); + auto face = canvas([ + left = frame.vu.vuLDb, + right = frame.vu.vuRDb, + leftPeak = frame.vu.peakLDb, + rightPeak = frame.vu.peakRDb, + combinedDb, + combinedPeak, + combined, + referenceDbfs + ](Canvas& surface) { + constexpr float pi = 3.14159265358979323846f; + const int canvasWidth = surface.width(); + const int canvasHeight = surface.height(); + if (canvasWidth < 8 || canvasHeight < 8) return; + const float startAngle = pi * 1.08f; + const float endAngle = pi * 1.92f; + const int centerX = canvasWidth / 2; + const int centerY = canvasHeight - 2; + const int radiusX = std::max(3, static_cast( + static_cast(centerX - 2) / std::abs(std::cos(startAngle)))); + const int radiusY = std::max(3, std::min( + centerY - 2, + static_cast(static_cast(canvasHeight) * 0.78f))); + const auto point = [&](float angle, float scale) { + return std::pair{ + centerX + static_cast(std::lround( + std::cos(angle) * static_cast(radiusX) * scale)), + centerY + static_cast(std::lround( + std::sin(angle) * static_cast(radiusY) * scale)), + }; + }; + const auto drawArc = [&](float from, float to, float scale, const Color& color) { + constexpr int segments = 80; + auto previous = point(from, scale); + for (int index = 1; index <= segments; ++index) { + const float amount = static_cast(index) / + static_cast(segments); + const float angle = from + (to - from) * amount; + const auto next = point(angle, scale); + surface.DrawPointLine( + previous.first, previous.second, + next.first, next.second, color); + previous = next; + } + }; + const auto angleForDb = [&](float db) { + return startAngle + vuDbToNormalized(db, referenceDbfs) * + (endAngle - startAngle); + }; + const auto drawNeedle = [&](float db, float peak, float scale, const Color& color) { + const float angle = angleForDb(db); + const auto tip = point(angle, scale * 0.92f); + surface.DrawPointLine(centerX, centerY, tip.first, tip.second, color); + const float peakAngle = angleForDb(peak); + const auto peakInner = point(peakAngle, scale * 0.91f); + const auto peakOuter = point(peakAngle, scale * 1.04f); + surface.DrawPointLine( + peakInner.first, peakInner.second, + peakOuter.first, peakOuter.second, + dbfsToClassicVu(peak, referenceDbfs) > 0.0f + ? Color::RedLight + : color); + }; + + drawArc(startAngle, endAngle, 1.0f, Color::GrayDark); + if (!combined) drawArc(startAngle, endAngle, 0.78f, Color::RGB(54, 64, 68)); + const std::array ticks = { + -20.0f, -10.0f, -5.0f, -3.0f, -1.0f, 0.0f, 1.0f, 2.0f, 3.0f}; + for (float vu : ticks) { + const float angle = startAngle + classicVuToNormalized(vu) * + (endAngle - startAngle); + const auto inner = point(angle, 0.92f); + const auto outer = point(angle, 1.05f); + surface.DrawPointLine( + inner.first, inner.second, + outer.first, outer.second, + vu >= 0.0f ? Color::Red : Color::GrayLight); + } + const float hotAngle = startAngle + classicVuToNormalized(0.0f) * + (endAngle - startAngle); + drawArc(hotAngle, endAngle, 1.0f, Color::Red); + + if (combined) { + drawArc(startAngle, angleForDb(combinedDb), 1.0f, Color::Cyan); + drawNeedle(combinedDb, combinedPeak, 1.0f, Color::CyanLight); + } else { + drawArc(startAngle, angleForDb(left), 0.78f, Color::BlueLight); + drawArc(startAngle, angleForDb(right), 1.0f, Color::Cyan); + drawNeedle(left, leftPeak, 0.78f, Color::BlueLight); + drawNeedle(right, rightPeak, 1.0f, Color::CyanLight); + } + surface.DrawPointCircleFilled(centerX, centerY, 1, Color::CyanLight); + }) | size(HEIGHT, EQUAL, plotRows) | flex; + + const std::string readings = combined + ? formatDb(combinedDb) + " dB" + : "L " + formatDb(frame.vu.vuLDb) + " dB " + + formatDb(frame.vu.vuRDb) + " dB R"; + return vbox({ + std::move(face), + text(readings) | center | color(Color::CyanLight), + renderCorrelationGauge( + frame.vu.correlation, std::max(5, contentWidth - 8)) | center, + }); +} + +ftxui::Element renderVUMeterPanel(const DisplayFrame& frame, + int width, + int height, + bool focused, + const TuiSettings& settings) { using namespace ftxui; const int contentWidth = std::max(1, width - 2); const int contentHeight = std::max(1, height - 2); - const size_t meterWidth = static_cast(std::max(4, contentWidth - 14)); + std::string detail = vuMeterModeName(settings.vuMeterMode); + if (width >= 42 && settings.vuMeterMode == VUMeterMode::Bar) { + detail += " • " + std::string(vuMeterOrientationName( + settings.vuMeterOrientation)); + } else if (width >= 42) { + detail += " • " + std::string(vuNeedleChannelsName( + settings.vuNeedleChannels)); + } + if (width >= 58) { + detail += " • 0 VU " + formatVuReference(settings.vuReferenceDbfs); + } - const auto meterRow = [&](const char* label, float level, float peak) { - return hbox({ - text(std::string(label) + " ") | bold, - text(buildMeterBar(level, peak, meterWidth)) | color(Color::Cyan), - text(" " + formatDb(level) + " dB"), - }); - }; - - Elements body; - body.push_back(meterRow("L", frame.vu.barLDb, frame.vu.peakLDb)); - body.push_back(meterRow("R", frame.vu.barRDb, frame.vu.peakRDb)); - if (contentHeight >= 7) { - body.push_back(separatorEmpty()); - const auto loudnessRow = [&](const char* label, float value) { - return hbox({ - text(label) | color(Color::Yellow), - filler(), - text(formatLufs(value) + " LUFS") | color(Color::YellowLight), - }); - }; - body.push_back(loudnessRow("Momentary", frame.lufs.momentaryLUFS)); - body.push_back(loudnessRow("Short term", frame.lufs.shortTermLUFS)); - body.push_back(loudnessRow("Integrated", frame.lufs.integratedLUFS)); + Element body; + if (settings.vuMeterMode == VUMeterMode::Needle) { + body = renderNeedleVu(frame, contentWidth, contentHeight, settings); + } else if (settings.vuMeterOrientation == VUMeterOrientation::Vertical) { + body = renderVerticalVu(frame, contentWidth, contentHeight, settings); } else { - const std::string lufs = - "LUFS M " + formatLufs(frame.lufs.momentaryLUFS) + - " S " + formatLufs(frame.lufs.shortTermLUFS) + - " I " + formatLufs(frame.lufs.integratedLUFS); - body.push_back(text(lufs) | color(Color::Yellow)); + body = renderHorizontalVu(frame, contentWidth, contentHeight, settings); } - while (static_cast(body.size()) < contentHeight) { - body.push_back(filler()); + auto panel = window( + panelTitle(PanelId::VUMeter, focused, detail), + std::move(body)); + return stylePanel(std::move(panel), focused) | + size(WIDTH, EQUAL, std::max(1, width)) | + size(HEIGHT, EQUAL, std::max(1, height)); +} + +std::string lufsScaleLabel(int row, int rows) { + const std::array ticks = {0, -6, -12, -24, -36, -50}; + for (int tick : ticks) { + const int tickRow = static_cast(std::lround( + (1.0f - compactMeterToNormalized(static_cast(tick))) * + static_cast(std::max(0, rows - 1)))); + if (tickRow == row) { + std::ostringstream label; + label << std::setw(3) << std::abs(tick); + return label.str(); + } + } + return " "; +} + +ftxui::Element renderLufsBarCell(float levelDb, + float peakDb, + int row, + int rows, + int width, + bool showPeak, + bool targetRow) { + using namespace ftxui; + const float level = compactMeterToNormalized(levelDb); + const float peak = compactMeterToNormalized(peakDb); + const float top = 1.0f - static_cast(row) / static_cast(rows); + const float bottom = 1.0f - static_cast(row + 1) / static_cast(rows); + const bool peakHere = showPeak && peak > bottom && peak <= top; + const bool filled = level > bottom; + const auto repeat = [width](const char* glyph) { + std::string result; + for (int index = 0; index < width; ++index) result += glyph; + return result; + }; + if (targetRow) return text(repeat("─")) | + color(Color::RedLight); + if (peakHere) return text(repeat("━")) | + color(Color::CyanLight); + if (filled) return text(repeat("█")) | + color(Color::Cyan); + return text(repeat("·")) | + color(Color::GrayDark); +} + +ftxui::Element renderLUFSMeterPanel(const DisplayFrame& frame, + int width, + int height, + bool focused, + const TuiSettings& settings) { + using namespace ftxui; + constexpr float targetLufs = -14.0f; + const int contentHeight = std::max(1, height - 2); + const int meterRows = std::max(1, contentHeight - 1); + const float selected = selectLufsReadout( + frame.lufs.momentaryLUFS, + frame.lufs.shortTermLUFS, + frame.lufs.integratedLUFS, + settings.lufsReadout); + const int selectedRow = static_cast(std::lround( + (1.0f - compactMeterToNormalized(selected)) * + static_cast(std::max(0, meterRows - 1)))); + const int targetRow = static_cast(std::lround( + (1.0f - compactMeterToNormalized(targetLufs)) * + static_cast(std::max(0, meterRows - 1)))); + + Elements rows; + rows.push_back(hbox({ + text(" L R LUFS") | dim, + filler(), + text("target -14") | color(Color::RedLight) | dim, + })); + for (int row = 0; row < meterRows; ++row) { + Elements parts; + const auto gap = [&]() { + auto element = text(row == targetRow ? "─" : " "); + return row == targetRow + ? element | color(Color::RedLight) + : element; + }; + parts.push_back(text(lufsScaleLabel(row, meterRows)) | dim); + parts.push_back(gap()); + parts.push_back(renderLufsBarCell( + frame.lufs.barLDb, frame.lufs.peakLDb, + row, meterRows, 2, true, row == targetRow)); + parts.push_back(gap()); + parts.push_back(renderLufsBarCell( + frame.lufs.barRDb, frame.lufs.peakRDb, + row, meterRows, 2, true, row == targetRow)); + parts.push_back(gap()); + parts.push_back(renderLufsBarCell( + selected, selected, + row, meterRows, 3, false, row == targetRow)); + parts.push_back(text(" ")); + if (row == selectedRow) { + parts.push_back( + text(" " + formatLufs(selected) + " LUFS ") | + bgcolor(Color::Cyan) | color(Color::Black) | bold); + } + rows.push_back(hbox(std::move(parts))); } + std::string detail = lufsReadoutName(settings.lufsReadout); + if (width >= 52) { + detail += " • M " + formatLufs(frame.lufs.momentaryLUFS) + + " • S " + formatLufs(frame.lufs.shortTermLUFS) + + " • I " + formatLufs(frame.lufs.integratedLUFS); + } auto panel = window( - panelTitle(PanelId::Levels, focused), - vbox(std::move(body))); + panelTitle(PanelId::LUFSMeter, focused, detail), + vbox(std::move(rows))); return stylePanel(std::move(panel), focused) | size(WIDTH, EQUAL, std::max(1, width)) | size(HEIGHT, EQUAL, std::max(1, height)); @@ -551,8 +939,20 @@ ftxui::Element renderLayoutNode(const LayoutNode& node, rect->height, focused, state.settings); - case PanelId::Levels: - return renderLevelsPanel(frame, rect->width, rect->height, focused); + case PanelId::VUMeter: + return renderVUMeterPanel( + frame, + rect->width, + rect->height, + focused, + state.settings); + case PanelId::LUFSMeter: + return renderLUFSMeterPanel( + frame, + rect->width, + rect->height, + focused, + state.settings); } } @@ -1025,7 +1425,8 @@ int runInteractive(std::unique_ptr capture, 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 (event == Event::Character('4')) selectedPanel = PanelId::VUMeter; + if (event == Event::Character('5')) selectedPanel = PanelId::LUFSMeter; if (selectedPanel) { interfaceState.focusedPanel = *selectedPanel; if (interfaceState.expandedPanel) { diff --git a/tui/src/tui_settings.cpp b/tui/src/tui_settings.cpp index 2867c32..0758721 100644 --- a/tui/src/tui_settings.cpp +++ b/tui/src/tui_settings.cpp @@ -34,6 +34,17 @@ const std::vector kVectorscopeSettings = { {SettingId::VectorscopeDetail, "Point detail", "Balances point density against terminal rendering cost."}, }; +const std::vector kVUMeterSettings = { + {SettingId::VUMeterMode, "Display mode", "Switches between Prism's bar and classic needle faces."}, + {SettingId::VUMeterOrientation, "Bar orientation", "Chooses horizontal or vertical bars when bar mode is active."}, + {SettingId::VUNeedleChannels, "Needle channels", "Shows stereo needles or one power-averaged needle."}, + {SettingId::VUReferenceLevel, "0 VU reference", "Calibrates 0 VU to a dBFS reference level."}, +}; + +const std::vector kLUFSMeterSettings = { + {SettingId::LUFSReadout, "Loudness readout", "Chooses which LUFS window drives the main bar and badge."}, +}; + float snap(float value, float step) { return std::round(value / step) * step; } @@ -72,6 +83,27 @@ std::string serializeVectorDetail(VectorscopeDetail detail) { return "detailed"; } +std::string serializeVuMode(VUMeterMode mode) { + return mode == VUMeterMode::Needle ? "needle" : "bar"; +} + +std::string serializeVuOrientation(VUMeterOrientation orientation) { + return orientation == VUMeterOrientation::Vertical ? "vertical" : "horizontal"; +} + +std::string serializeVuNeedleChannels(VUNeedleChannels channels) { + return channels == VUNeedleChannels::Combined ? "combined" : "stereo"; +} + +std::string serializeLufsReadout(LUFSReadout readout) { + switch (readout) { + case LUFSReadout::Momentary: return "momentary"; + case LUFSReadout::ShortTerm: return "short_term"; + case LUFSReadout::Integrated: return "integrated"; + } + return "short_term"; +} + 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; @@ -122,6 +154,33 @@ VectorscopeDetail parseVectorDetail(const std::string& value, return fallback; } +VUMeterMode parseVuMode(const std::string& value, VUMeterMode fallback) { + if (value == "bar") return VUMeterMode::Bar; + if (value == "needle") return VUMeterMode::Needle; + return fallback; +} + +VUMeterOrientation parseVuOrientation(const std::string& value, + VUMeterOrientation fallback) { + if (value == "horizontal") return VUMeterOrientation::Horizontal; + if (value == "vertical") return VUMeterOrientation::Vertical; + return fallback; +} + +VUNeedleChannels parseVuNeedleChannels(const std::string& value, + VUNeedleChannels fallback) { + if (value == "stereo") return VUNeedleChannels::Stereo; + if (value == "combined") return VUNeedleChannels::Combined; + return fallback; +} + +LUFSReadout parseLufsReadout(const std::string& value, LUFSReadout fallback) { + if (value == "momentary") return LUFSReadout::Momentary; + if (value == "short_term") return LUFSReadout::ShortTerm; + if (value == "integrated") return LUFSReadout::Integrated; + return fallback; +} + const char* environmentValue(const char* name) { const char* value = std::getenv(name); return value != nullptr && value[0] != '\0' ? value : nullptr; @@ -135,6 +194,8 @@ TuiSettings normalizeSettings(TuiSettings settings) { settings.spectrumTiltDbPerOctave = std::clamp( snap(settings.spectrumTiltDbPerOctave, 0.1f), -2.0f, 8.0f); settings.oscilloscopeTraceWeight = std::clamp(settings.oscilloscopeTraceWeight, 1, 3); + settings.vuReferenceDbfs = std::clamp( + snap(settings.vuReferenceDbfs, 1.0f), -30.0f, 0.0f); if (!settings.oscilloscopePitchLock) { settings.oscilloscopeFrequencyReadout = false; } @@ -152,7 +213,12 @@ bool operator==(const TuiSettings& left, const TuiSettings& right) { left.oscilloscopeTraceWeight == right.oscilloscopeTraceWeight && left.vectorscopeMode == right.vectorscopeMode && left.vectorscopeGuides == right.vectorscopeGuides && - left.vectorscopeDetail == right.vectorscopeDetail; + left.vectorscopeDetail == right.vectorscopeDetail && + left.vuMeterMode == right.vuMeterMode && + left.vuMeterOrientation == right.vuMeterOrientation && + left.vuNeedleChannels == right.vuNeedleChannels && + left.vuReferenceDbfs == right.vuReferenceDbfs && + left.lufsReadout == right.lufsReadout; } bool operator!=(const TuiSettings& left, const TuiSettings& right) { @@ -165,6 +231,8 @@ std::vector settingsPages() { SettingsPage::Spectrum, SettingsPage::Oscilloscope, SettingsPage::Vectorscope, + SettingsPage::VUMeter, + SettingsPage::LUFSMeter, }; } @@ -175,6 +243,8 @@ const char* settingsPageName(SettingsPage page) { case SettingsPage::Spectrum: return "Spectrum"; case SettingsPage::Oscilloscope: return "Oscilloscope"; case SettingsPage::Vectorscope: return "Vectorscope"; + case SettingsPage::VUMeter: return "VU meter"; + case SettingsPage::LUFSMeter: return "LUFS meter"; } return "Settings"; } @@ -186,6 +256,8 @@ const char* settingsPageDescription(SettingsPage page) { 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."; + case SettingsPage::VUMeter: return "Classic level, peak, and phase metering."; + case SettingsPage::LUFSMeter: return "Loudness window and target presentation."; } return {}; } @@ -196,6 +268,8 @@ const std::vector& settingsForPage(SettingsPage page) { case SettingsPage::Spectrum: return kSpectrumSettings; case SettingsPage::Oscilloscope: return kOscilloscopeSettings; case SettingsPage::Vectorscope: return kVectorscopeSettings; + case SettingsPage::VUMeter: return kVUMeterSettings; + case SettingsPage::LUFSMeter: return kLUFSMeterSettings; case SettingsPage::Home: break; } static const std::vector empty; @@ -231,6 +305,16 @@ std::string settingValue(const TuiSettings& settings, SettingId setting) { case VectorscopeDetail::Detailed: return "Detailed"; case VectorscopeDetail::Maximum: return "Maximum"; } + case SettingId::VUMeterMode: + return vuMeterModeName(settings.vuMeterMode); + case SettingId::VUMeterOrientation: + return vuMeterOrientationName(settings.vuMeterOrientation); + case SettingId::VUNeedleChannels: + return vuNeedleChannelsName(settings.vuNeedleChannels); + case SettingId::VUReferenceLevel: + return trimFloat(settings.vuReferenceDbfs, 0) + " dBFS"; + case SettingId::LUFSReadout: + return lufsReadoutName(settings.lufsReadout); } return {}; } @@ -302,6 +386,32 @@ bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) { settings.vectorscopeDetail = static_cast(value); break; } + case SettingId::VUMeterMode: + settings.vuMeterMode = settings.vuMeterMode == VUMeterMode::Bar + ? VUMeterMode::Needle + : VUMeterMode::Bar; + break; + case SettingId::VUMeterOrientation: + settings.vuMeterOrientation = + settings.vuMeterOrientation == VUMeterOrientation::Horizontal + ? VUMeterOrientation::Vertical + : VUMeterOrientation::Horizontal; + break; + case SettingId::VUNeedleChannels: + settings.vuNeedleChannels = + settings.vuNeedleChannels == VUNeedleChannels::Stereo + ? VUNeedleChannels::Combined + : VUNeedleChannels::Stereo; + break; + case SettingId::VUReferenceLevel: + settings.vuReferenceDbfs += direction > 0 ? 1.0f : -1.0f; + break; + case SettingId::LUFSReadout: { + int value = static_cast(settings.lufsReadout); + value = (value + (direction > 0 ? 1 : 2)) % 3; + settings.lufsReadout = static_cast(value); + break; + } } settings = normalizeSettings(settings); return settings != before; @@ -322,6 +432,11 @@ bool resetSetting(TuiSettings& settings, SettingId setting) { case SettingId::VectorscopeMode: settings.vectorscopeMode = defaults.vectorscopeMode; break; case SettingId::VectorscopeGuides: settings.vectorscopeGuides = defaults.vectorscopeGuides; break; case SettingId::VectorscopeDetail: settings.vectorscopeDetail = defaults.vectorscopeDetail; break; + case SettingId::VUMeterMode: settings.vuMeterMode = defaults.vuMeterMode; break; + case SettingId::VUMeterOrientation: settings.vuMeterOrientation = defaults.vuMeterOrientation; break; + case SettingId::VUNeedleChannels: settings.vuNeedleChannels = defaults.vuNeedleChannels; break; + case SettingId::VUReferenceLevel: settings.vuReferenceDbfs = defaults.vuReferenceDbfs; break; + case SettingId::LUFSReadout: settings.lufsReadout = defaults.lufsReadout; break; } return settings != before; } @@ -367,6 +482,11 @@ TuiSettings loadSettings(const std::filesystem::path& path) { 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); + else if (key == "vu_mode") settings.vuMeterMode = parseVuMode(value, settings.vuMeterMode); + else if (key == "vu_orientation") settings.vuMeterOrientation = parseVuOrientation(value, settings.vuMeterOrientation); + 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); } return normalizeSettings(settings); } @@ -398,7 +518,12 @@ bool saveSettings(const TuiSettings& rawSettings, << "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'; + << "vector_detail=" << serializeVectorDetail(settings.vectorscopeDetail) << '\n' + << "vu_mode=" << serializeVuMode(settings.vuMeterMode) << '\n' + << "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'; 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 5983a37..35cfa03 100644 --- a/tui/src/tui_settings.h +++ b/tui/src/tui_settings.h @@ -1,6 +1,7 @@ #pragma once #include "dashboard_layout.h" +#include "meter_display_model.h" #include "scope_plot_model.h" #include @@ -15,6 +16,8 @@ enum class SettingsPage { Spectrum, Oscilloscope, Vectorscope, + VUMeter, + LUFSMeter, }; enum class SettingId { @@ -29,6 +32,11 @@ enum class SettingId { VectorscopeMode, VectorscopeGuides, VectorscopeDetail, + VUMeterMode, + VUMeterOrientation, + VUNeedleChannels, + VUReferenceLevel, + LUFSReadout, }; enum class VectorscopeDetail { @@ -49,6 +57,11 @@ struct TuiSettings { VectorscopeMode vectorscopeMode = VectorscopeMode::Lissajous; bool vectorscopeGuides = true; VectorscopeDetail vectorscopeDetail = VectorscopeDetail::Detailed; + VUMeterMode vuMeterMode = VUMeterMode::Bar; + VUMeterOrientation vuMeterOrientation = VUMeterOrientation::Horizontal; + VUNeedleChannels vuNeedleChannels = VUNeedleChannels::Stereo; + float vuReferenceDbfs = -14.0f; + LUFSReadout lufsReadout = LUFSReadout::ShortTerm; }; struct SettingDescriptor { diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index cb731fe..9227ba0 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 "meter_display_model.h" #include "scope_plot_model.h" #include "snapshot_store.h" #include "spectrum_peak_model.h" @@ -155,29 +156,36 @@ void testProjectionAndLayout() { require(!wide.terminalTooSmall && wide.resolvedPreset == Prism::Tui::LayoutPreset::Columns, "wide, tall terminals should use the columns dashboard"); - require(wide.panels.size() == 4 && + require(wide.panels.size() == 5 && 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"); + wide.panels[3].panel == Prism::Tui::PanelId::VUMeter && + wide.panels[4].panel == Prism::Tui::PanelId::LUFSMeter, + "the dashboard should contain all five scope panels"); require(wide.panels[0].width == wide.panels[1].width && wide.panels[2].width == wide.panels[3].width && + wide.panels[3].width == wide.panels[4].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, + wide.panels[2].height + wide.panels[3].height + + wide.panels[4].height == 28, "both dashboard columns should fill the available height"); const auto stacked = Prism::Tui::buildDashboardLayout( 60, 20, Prism::Tui::LayoutPreset::Automatic); require(stacked.resolvedPreset == Prism::Tui::LayoutPreset::Stacked, "short terminals should stack their panels"); - require(stacked.panels.size() == 2 && + require(stacked.panels.size() == 3 && + stacked.panels[0].panel == Prism::Tui::PanelId::Spectrum && + stacked.panels[1].panel == Prism::Tui::PanelId::VUMeter && + stacked.panels[2].panel == Prism::Tui::PanelId::LUFSMeter && stacked.panels[0].height + stacked.panels[1].height == 18 && - stacked.panels[0].height > stacked.panels[1].height, - "stacked panels should fill the dashboard and favor the spectrum"); + stacked.panels[1].height == stacked.panels[2].height && + stacked.panels[1].width + stacked.panels[2].width == 60, + "compact dashboards should keep VU and LUFS as separate scopes"); const auto minimum = Prism::Tui::buildDashboardLayout( 44, 12, Prism::Tui::LayoutPreset::Automatic); @@ -192,24 +200,44 @@ void testProjectionAndLayout() { "short resize should select the compact screen"); const auto expanded = Prism::Tui::buildDashboardLayout( - 100, 30, Prism::Tui::LayoutPreset::Columns, Prism::Tui::PanelId::Levels); + 100, 30, Prism::Tui::LayoutPreset::Columns, Prism::Tui::PanelId::LUFSMeter); require(expanded.panels.size() == 1 && - expanded.panels[0].panel == Prism::Tui::PanelId::Levels && + expanded.panels[0].panel == Prism::Tui::PanelId::LUFSMeter && 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::Oscilloscope, "panel focus should cycle forward"); require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) == - Prism::Tui::PanelId::Levels, + Prism::Tui::PanelId::LUFSMeter, "panel focus should cycle backward"); const auto compactPanels = Prism::Tui::visiblePanelOrder(stacked); - require(compactPanels.size() == 2 && + require(compactPanels.size() == 3 && Prism::Tui::nextPanel( - Prism::Tui::PanelId::Spectrum, compactPanels) == Prism::Tui::PanelId::Levels, + Prism::Tui::PanelId::Spectrum, compactPanels) == Prism::Tui::PanelId::VUMeter, "compact layout focus should skip hidden visual scopes"); } +void testMeterDisplayModels() { + require(std::abs(Prism::Tui::dbfsToClassicVu(-14.0f, -14.0f)) < 0.001f, + "the reference level should map exactly to 0 VU"); + require(std::abs( + Prism::Tui::classicVuToNormalized(0.0f) - 0.81f) < 0.001f, + "the TUI should preserve Prism's classic nonlinear VU scale"); + require(Prism::Tui::classicVuToNormalized(-10.0f) < + Prism::Tui::classicVuToNormalized(-5.0f), + "classic VU projection should remain monotonic"); + require(Prism::Tui::compactMeterToNormalized(-50.0f) == 0.0f && + Prism::Tui::compactMeterToNormalized(0.0f) == 1.0f, + "LUFS compact bars should use the GUI's -50 to 0 range"); + require(std::abs( + Prism::Tui::stereoRmsDbAverage(-10.0f, -10.0f) + 10.0f) < 0.001f, + "combined VU needles should average channels in the power domain"); + require(Prism::Tui::selectLufsReadout( + -10.0f, -12.0f, -14.0f, Prism::Tui::LUFSReadout::Integrated) == -14.0f, + "LUFS readout selection should drive the dedicated loudness bar"); +} + void testSpectrumPeakModel() { constexpr float sampleRate = 48000.0f; constexpr size_t fftSize = 4096; @@ -251,7 +279,7 @@ void testSettingsModelAndPersistence() { "settings normalization should enforce public ranges"); const auto pages = Prism::Tui::settingsPages(); - require(pages.size() == 4 && + require(pages.size() == 6 && Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 3, "settings should expose shallow category pages"); Prism::Tui::TuiSettings adjusted; @@ -276,6 +304,14 @@ void testSettingsModelAndPersistence() { adjusted, Prism::Tui::SettingId::OscilloscopePitchLock, 1) && adjusted.oscilloscopePitchLock, "pitch lock should remain independently re-enableable"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::VUMeterMode, 1) && + adjusted.vuMeterMode == Prism::Tui::VUMeterMode::Needle, + "VU settings should expose the GUI's needle presentation"); + require(Prism::Tui::adjustSetting( + adjusted, Prism::Tui::SettingId::LUFSReadout, 1) && + adjusted.lufsReadout == Prism::Tui::LUFSReadout::Integrated, + "LUFS settings should select an independent loudness window"); require(Prism::Tui::resetSetting( adjusted, Prism::Tui::SettingId::InputTrim) && adjusted.inputTrimDb == 0.0f, @@ -552,6 +588,7 @@ void testThreadSafeSnapshots() { int main() { testCli(); testProjectionAndLayout(); + testMeterDisplayModels(); testSpectrumPeakModel(); testSettingsModelAndPersistence(); testScopePlotModels();