add vu and lufs

This commit is contained in:
Boof2015
2026-08-14 19:19:53 -04:00
parent c45451476d
commit 5910005dd4
10 changed files with 804 additions and 68 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -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";
}
+20 -7
View File
@@ -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<PanelId> panelOrder() {
PanelId::Spectrum,
PanelId::Oscilloscope,
PanelId::Vectorscope,
PanelId::Levels,
PanelId::VUMeter,
PanelId::LUFSMeter,
};
}
+2 -1
View File
@@ -10,7 +10,8 @@ enum class PanelId {
Spectrum,
Oscilloscope,
Vectorscope,
Levels,
VUMeter,
LUFSMeter,
};
enum class SplitAxis {
+104
View File
@@ -0,0 +1,104 @@
#include "meter_display_model.h"
#include <algorithm>
#include <array>
#include <cmath>
namespace Prism::Tui {
namespace {
struct VuAnchor {
float vu;
float normalized;
};
constexpr std::array<VuAnchor, 12> 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
+41
View File
@@ -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
+445 -44
View File
@@ -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<size_t, 5> settingsSelections{};
std::array<size_t, 7> 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<int>(std::lround(
vuDbToNormalized(levelDb, referenceDbfs) * resolvedColumns)),
0,
resolvedColumns);
const int peakColumn = std::clamp(
static_cast<int>(std::lround(
vuDbToNormalized(peakDb, referenceDbfs) * (resolvedColumns - 1))),
0,
resolvedColumns - 1);
const int hotColumn = static_cast<int>(std::lround(
classicVuToNormalized(0.0f) * resolvedColumns));
const bool showPeak = std::isfinite(peakDb) && peakDb > -59.0f;
Elements cells;
cells.reserve(static_cast<size_t>(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<int>(std::lround(
std::abs(clamped) * static_cast<float>(center)));
Elements cells;
cells.reserve(static_cast<size_t>(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<size_t>(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<size_t>(std::lround(
classicVuToNormalized(vu) * static_cast<float>(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<float>(row) / static_cast<float>(rows);
const float bottom = 1.0f - static_cast<float>(row + 1) / static_cast<float>(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<int>(
static_cast<float>(centerX - 2) / std::abs(std::cos(startAngle))));
const int radiusY = std::max(3, std::min(
centerY - 2,
static_cast<int>(static_cast<float>(canvasHeight) * 0.78f)));
const auto point = [&](float angle, float scale) {
return std::pair<int, int>{
centerX + static_cast<int>(std::lround(
std::cos(angle) * static_cast<float>(radiusX) * scale)),
centerY + static_cast<int>(std::lround(
std::sin(angle) * static_cast<float>(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<float>(index) /
static_cast<float>(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<float, 9> 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<size_t>(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<int>(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<int, 6> ticks = {0, -6, -12, -24, -36, -50};
for (int tick : ticks) {
const int tickRow = static_cast<int>(std::lround(
(1.0f - compactMeterToNormalized(static_cast<float>(tick))) *
static_cast<float>(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<float>(row) / static_cast<float>(rows);
const float bottom = 1.0f - static_cast<float>(row + 1) / static_cast<float>(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<int>(std::lround(
(1.0f - compactMeterToNormalized(selected)) *
static_cast<float>(std::max(0, meterRows - 1))));
const int targetRow = static_cast<int>(std::lround(
(1.0f - compactMeterToNormalized(targetLufs)) *
static_cast<float>(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<Prism::Capture::SystemAudioCapture> 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) {
+127 -2
View File
@@ -34,6 +34,17 @@ const std::vector<SettingDescriptor> kVectorscopeSettings = {
{SettingId::VectorscopeDetail, "Point detail", "Balances point density against terminal rendering cost."},
};
const std::vector<SettingDescriptor> 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<SettingDescriptor> 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<SettingsPage> 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<SettingDescriptor>& 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<SettingDescriptor> 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<VectorscopeDetail>(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<int>(settings.lufsReadout);
value = (value + (direction > 0 ? 1 : 2)) % 3;
settings.lufsReadout = static_cast<LUFSReadout>(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;
+13
View File
@@ -1,6 +1,7 @@
#pragma once
#include "dashboard_layout.h"
#include "meter_display_model.h"
#include "scope_plot_model.h"
#include <filesystem>
@@ -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 {
+50 -13
View File
@@ -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();