mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-16 08:10:40 +02:00
add vu and lufs
This commit is contained in:
@@ -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
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ enum class PanelId {
|
||||
Spectrum,
|
||||
Oscilloscope,
|
||||
Vectorscope,
|
||||
Levels,
|
||||
VUMeter,
|
||||
LUFSMeter,
|
||||
};
|
||||
|
||||
enum class SplitAxis {
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user