mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-17 19:24:03 +02:00
early tui layout
This commit is contained in:
@@ -51,7 +51,9 @@ Capture-to-display latency measures under 8ms. When tested at 120fps, measured l
|
|||||||
|
|
||||||
Installable Prism packages also provide `prism-tui`, a native terminal frontend
|
Installable Prism packages also provide `prism-tui`, a native terminal frontend
|
||||||
for the shared C++ capture and analysis engine. It shows a responsive spectrum,
|
for the shared C++ capture and analysis engine. It shows a responsive spectrum,
|
||||||
stereo VU meters, and momentary, short-term, and integrated LUFS readings.
|
stereo VU meters, and momentary, short-term, and integrated LUFS readings. The
|
||||||
|
dashboard uses a 4096-point FFT by default and automatically switches between
|
||||||
|
stacked and column layouts as the terminal is resized.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
prism-tui # Capture the default system output
|
prism-tui # Capture the default system output
|
||||||
@@ -61,11 +63,13 @@ prism-tui --help
|
|||||||
prism-tui --version
|
prism-tui --version
|
||||||
```
|
```
|
||||||
|
|
||||||
Press `r` to reset the analyzers and integrated loudness, or `q`, Escape, or
|
Press Tab or Shift-Tab to focus a panel, Enter to expand or restore it, and `l`
|
||||||
Ctrl-C to quit. Interactive mode requires a terminal of at least 44 by 12 cells.
|
to cycle automatic, stacked, and column layouts. Number keys `1` and `2` focus
|
||||||
Quote a device ID if it contains spaces. Successful help, version, listing, and
|
Spectrum and Levels. Press `r` to reset the analyzers and integrated loudness,
|
||||||
interactive exits return `0`; usage errors return `2`; capture and runtime
|
or `q`, Escape, or Ctrl-C to quit. Interactive mode requires a terminal of at
|
||||||
failures return `1`.
|
least 44 by 12 cells. Quote a device ID if it contains spaces. Successful help,
|
||||||
|
version, listing, and interactive exits return `0`; usage errors return `2`;
|
||||||
|
capture and runtime failures return `1`.
|
||||||
|
|
||||||
The v0 TUI captures system output only. Microphone/device-input capture, Prism
|
The v0 TUI captures system output only. Microphone/device-input capture, Prism
|
||||||
profiles and themes, file/stdin analysis, and the other visualizers remain GUI
|
profiles and themes, file/stdin analysis, and the other visualizers remain GUI
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ find_package(Threads REQUIRED)
|
|||||||
add_library(prism_tui_analysis STATIC
|
add_library(prism_tui_analysis STATIC
|
||||||
src/analysis_pipeline.cpp
|
src/analysis_pipeline.cpp
|
||||||
src/cli.cpp
|
src/cli.cpp
|
||||||
|
src/dashboard_layout.cpp
|
||||||
src/display_model.cpp
|
src/display_model.cpp
|
||||||
${PRISM_NATIVE_DIR}/spectrum.cpp
|
${PRISM_NATIVE_DIR}/spectrum.cpp
|
||||||
${PRISM_NATIVE_DIR}/vumeter.cpp
|
${PRISM_NATIVE_DIR}/vumeter.cpp
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
namespace Prism::Tui {
|
namespace Prism::Tui {
|
||||||
|
|
||||||
|
constexpr size_t kDefaultFftSize = 4096;
|
||||||
|
|
||||||
struct AnalysisFrame {
|
struct AnalysisFrame {
|
||||||
std::vector<float> magnitudes;
|
std::vector<float> magnitudes;
|
||||||
Visualizer::VUMeterSnapshot vu{};
|
Visualizer::VUMeterSnapshot vu{};
|
||||||
@@ -17,7 +19,7 @@ struct AnalysisFrame {
|
|||||||
|
|
||||||
class AnalysisPipeline {
|
class AnalysisPipeline {
|
||||||
public:
|
public:
|
||||||
explicit AnalysisPipeline(float sampleRate, size_t fftSize = 2048);
|
explicit AnalysisPipeline(float sampleRate, size_t fftSize = kDefaultFftSize);
|
||||||
|
|
||||||
void process(const Prism::Capture::AudioChunk& chunk);
|
void process(const Prism::Capture::AudioChunk& chunk);
|
||||||
AnalysisFrame snapshot();
|
AnalysisFrame snapshot();
|
||||||
|
|||||||
+8
-1
@@ -56,7 +56,14 @@ std::string usageText() {
|
|||||||
" --device <id> Capture a specific system output device.\n"
|
" --device <id> Capture a specific system output device.\n"
|
||||||
" --list-devices List available system output devices.\n"
|
" --list-devices List available system output devices.\n"
|
||||||
" -h, --help Show this help.\n"
|
" -h, --help Show this help.\n"
|
||||||
" -V, --version Show the Prism TUI version.\n";
|
" -V, --version Show the Prism TUI version.\n\n"
|
||||||
|
"Controls:\n"
|
||||||
|
" Tab / Shift-Tab Focus the next or previous panel.\n"
|
||||||
|
" Enter Expand the focused panel or restore the dashboard.\n"
|
||||||
|
" l Cycle automatic, stacked, and column layouts.\n"
|
||||||
|
" 1 / 2 Focus Spectrum or Levels.\n"
|
||||||
|
" r Reset analyzers and integrated loudness.\n"
|
||||||
|
" q / Esc / Ctrl-C Quit.\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace Prism::Tui
|
} // namespace Prism::Tui
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
#include "dashboard_layout.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <numeric>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace Prism::Tui {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct MinimumSize {
|
||||||
|
int width = 1;
|
||||||
|
int height = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
MinimumSize panelMinimumSize(PanelId panel) {
|
||||||
|
switch (panel) {
|
||||||
|
case PanelId::Spectrum:
|
||||||
|
return {30, 5};
|
||||||
|
case PanelId::Levels:
|
||||||
|
return {30, 5};
|
||||||
|
}
|
||||||
|
return {1, 1};
|
||||||
|
}
|
||||||
|
|
||||||
|
MinimumSize nodeMinimumSize(const LayoutNode& node) {
|
||||||
|
if (node.isLeaf()) {
|
||||||
|
return panelMinimumSize(*node.panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
MinimumSize result;
|
||||||
|
result.width = node.axis == SplitAxis::Columns ? 0 : 1;
|
||||||
|
result.height = node.axis == SplitAxis::Rows ? 0 : 1;
|
||||||
|
for (const auto& child : node.children) {
|
||||||
|
const auto childMinimum = nodeMinimumSize(child);
|
||||||
|
if (node.axis == SplitAxis::Columns) {
|
||||||
|
result.width += childMinimum.width;
|
||||||
|
result.height = std::max(result.height, childMinimum.height);
|
||||||
|
} else {
|
||||||
|
result.width = std::max(result.width, childMinimum.width);
|
||||||
|
result.height += childMinimum.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<int> partitionExtent(int total,
|
||||||
|
const std::vector<int>& weights,
|
||||||
|
const std::vector<int>& minimums) {
|
||||||
|
if (weights.empty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<int> result(weights.size(), 0);
|
||||||
|
const int minimumTotal = std::accumulate(minimums.begin(), minimums.end(), 0);
|
||||||
|
if (minimumTotal <= total) {
|
||||||
|
int remaining = total;
|
||||||
|
int remainingWeight = std::accumulate(weights.begin(), weights.end(), 0);
|
||||||
|
for (size_t index = 0; index < result.size(); ++index) {
|
||||||
|
const int weight = std::max(1, weights[index]);
|
||||||
|
result[index] = index + 1 == result.size()
|
||||||
|
? remaining
|
||||||
|
: remaining * weight / std::max(1, remainingWeight);
|
||||||
|
remaining -= result[index];
|
||||||
|
remainingWeight -= weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the requested ratio whenever possible, then borrow from
|
||||||
|
// larger panes to honor each panel's usable minimum size.
|
||||||
|
for (size_t index = 0; index < result.size(); ++index) {
|
||||||
|
int needed = std::max(0, minimums[index] - result[index]);
|
||||||
|
for (size_t donor = 0; donor < result.size() && needed > 0; ++donor) {
|
||||||
|
if (donor == index) continue;
|
||||||
|
const int available = std::max(0, result[donor] - minimums[donor]);
|
||||||
|
const int transfer = std::min(needed, available);
|
||||||
|
result[donor] -= transfer;
|
||||||
|
result[index] += transfer;
|
||||||
|
needed -= transfer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int remaining = std::max(0, total);
|
||||||
|
int remainingWeight = std::accumulate(weights.begin(), weights.end(), 0);
|
||||||
|
for (size_t index = 0; index < result.size(); ++index) {
|
||||||
|
const int weight = std::max(1, weights[index]);
|
||||||
|
result[index] = index + 1 == result.size()
|
||||||
|
? remaining
|
||||||
|
: remaining * weight / std::max(1, remainingWeight);
|
||||||
|
remaining -= result[index];
|
||||||
|
remainingWeight -= weight;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void resolveNode(const LayoutNode& node,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
std::vector<PanelRect>& output) {
|
||||||
|
if (node.isLeaf()) {
|
||||||
|
output.push_back({*node.panel, x, y, width, height});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.children.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<int> weights;
|
||||||
|
std::vector<int> minimums;
|
||||||
|
weights.reserve(node.children.size());
|
||||||
|
minimums.reserve(node.children.size());
|
||||||
|
for (const auto& child : node.children) {
|
||||||
|
weights.push_back(std::max(1, child.weight));
|
||||||
|
const auto minimum = nodeMinimumSize(child);
|
||||||
|
minimums.push_back(node.axis == SplitAxis::Columns
|
||||||
|
? minimum.width
|
||||||
|
: minimum.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int extent = node.axis == SplitAxis::Columns ? width : height;
|
||||||
|
const auto spans = partitionExtent(extent, weights, minimums);
|
||||||
|
int offset = 0;
|
||||||
|
for (size_t index = 0; index < node.children.size(); ++index) {
|
||||||
|
if (node.axis == SplitAxis::Columns) {
|
||||||
|
resolveNode(node.children[index], x + offset, y, spans[index], height, output);
|
||||||
|
} else {
|
||||||
|
resolveNode(node.children[index], x, y + offset, width, spans[index], output);
|
||||||
|
}
|
||||||
|
offset += spans[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) {
|
||||||
|
constexpr int minimumColumnsWidth = 72;
|
||||||
|
if (requested == LayoutPreset::Columns && width < minimumColumnsWidth) {
|
||||||
|
return LayoutPreset::Stacked;
|
||||||
|
}
|
||||||
|
if (requested != LayoutPreset::Automatic) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
return width >= 96 && height >= 28
|
||||||
|
? LayoutPreset::Columns
|
||||||
|
: LayoutPreset::Stacked;
|
||||||
|
}
|
||||||
|
|
||||||
|
LayoutNode makeRoot(LayoutPreset preset) {
|
||||||
|
if (preset == LayoutPreset::Columns) {
|
||||||
|
return LayoutNode::split(SplitAxis::Columns, {
|
||||||
|
LayoutNode::leaf(PanelId::Spectrum, 4),
|
||||||
|
LayoutNode::leaf(PanelId::Levels, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return LayoutNode::split(SplitAxis::Rows, {
|
||||||
|
LayoutNode::leaf(PanelId::Spectrum, 4),
|
||||||
|
LayoutNode::leaf(PanelId::Levels, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
LayoutNode LayoutNode::leaf(PanelId panel, int weight) {
|
||||||
|
LayoutNode node;
|
||||||
|
node.panel = panel;
|
||||||
|
node.weight = weight;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
LayoutNode LayoutNode::split(SplitAxis axis,
|
||||||
|
std::vector<LayoutNode> children,
|
||||||
|
int weight) {
|
||||||
|
LayoutNode node;
|
||||||
|
node.axis = axis;
|
||||||
|
node.weight = weight;
|
||||||
|
node.children = std::move(children);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardLayout buildDashboardLayout(int width,
|
||||||
|
int height,
|
||||||
|
LayoutPreset requestedPreset,
|
||||||
|
std::optional<PanelId> expandedPanel) {
|
||||||
|
DashboardLayout layout;
|
||||||
|
layout.requestedPreset = requestedPreset;
|
||||||
|
layout.terminalTooSmall = width < kMinimumTerminalWidth || height < kMinimumTerminalHeight;
|
||||||
|
if (layout.terminalTooSmall) {
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
layout.resolvedPreset = resolvePreset(requestedPreset, width, height);
|
||||||
|
layout.root = expandedPanel
|
||||||
|
? LayoutNode::leaf(*expandedPanel)
|
||||||
|
: makeRoot(layout.resolvedPreset);
|
||||||
|
|
||||||
|
// The header and footer each consume one terminal row.
|
||||||
|
resolveNode(layout.root, 0, 0, width, height - 2, layout.panels);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
LayoutPreset nextLayoutPreset(LayoutPreset preset) {
|
||||||
|
switch (preset) {
|
||||||
|
case LayoutPreset::Automatic:
|
||||||
|
return LayoutPreset::Stacked;
|
||||||
|
case LayoutPreset::Stacked:
|
||||||
|
return LayoutPreset::Columns;
|
||||||
|
case LayoutPreset::Columns:
|
||||||
|
return LayoutPreset::Automatic;
|
||||||
|
}
|
||||||
|
return LayoutPreset::Automatic;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string layoutPresetName(LayoutPreset preset) {
|
||||||
|
switch (preset) {
|
||||||
|
case LayoutPreset::Automatic:
|
||||||
|
return "auto";
|
||||||
|
case LayoutPreset::Stacked:
|
||||||
|
return "stacked";
|
||||||
|
case LayoutPreset::Columns:
|
||||||
|
return "columns";
|
||||||
|
}
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<PanelId> panelOrder() {
|
||||||
|
return {PanelId::Spectrum, PanelId::Levels};
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelId nextPanel(PanelId panel, bool reverse) {
|
||||||
|
const auto panels = panelOrder();
|
||||||
|
const auto found = std::find(panels.begin(), panels.end(), panel);
|
||||||
|
const size_t index = found == panels.end()
|
||||||
|
? 0
|
||||||
|
: static_cast<size_t>(std::distance(panels.begin(), found));
|
||||||
|
if (reverse) {
|
||||||
|
return panels[(index + panels.size() - 1) % panels.size()];
|
||||||
|
}
|
||||||
|
return panels[(index + 1) % panels.size()];
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Prism::Tui
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace Prism::Tui {
|
||||||
|
|
||||||
|
enum class PanelId {
|
||||||
|
Spectrum,
|
||||||
|
Levels,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class SplitAxis {
|
||||||
|
Rows,
|
||||||
|
Columns,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class LayoutPreset {
|
||||||
|
Automatic,
|
||||||
|
Stacked,
|
||||||
|
Columns,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LayoutNode {
|
||||||
|
std::optional<PanelId> panel;
|
||||||
|
SplitAxis axis = SplitAxis::Rows;
|
||||||
|
int weight = 1;
|
||||||
|
std::vector<LayoutNode> children;
|
||||||
|
|
||||||
|
static LayoutNode leaf(PanelId panel, int weight = 1);
|
||||||
|
static LayoutNode split(SplitAxis axis,
|
||||||
|
std::vector<LayoutNode> children,
|
||||||
|
int weight = 1);
|
||||||
|
bool isLeaf() const { return panel.has_value(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PanelRect {
|
||||||
|
PanelId panel = PanelId::Spectrum;
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DashboardLayout {
|
||||||
|
bool terminalTooSmall = true;
|
||||||
|
LayoutPreset requestedPreset = LayoutPreset::Automatic;
|
||||||
|
LayoutPreset resolvedPreset = LayoutPreset::Stacked;
|
||||||
|
LayoutNode root;
|
||||||
|
std::vector<PanelRect> panels;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr int kMinimumTerminalWidth = 44;
|
||||||
|
constexpr int kMinimumTerminalHeight = 12;
|
||||||
|
|
||||||
|
DashboardLayout buildDashboardLayout(int width,
|
||||||
|
int height,
|
||||||
|
LayoutPreset requestedPreset,
|
||||||
|
std::optional<PanelId> expandedPanel = std::nullopt);
|
||||||
|
LayoutPreset nextLayoutPreset(LayoutPreset preset);
|
||||||
|
std::string layoutPresetName(LayoutPreset preset);
|
||||||
|
std::vector<PanelId> panelOrder();
|
||||||
|
PanelId nextPanel(PanelId panel, bool reverse = false);
|
||||||
|
|
||||||
|
} // namespace Prism::Tui
|
||||||
@@ -29,18 +29,6 @@ void placeLabel(std::string& axis, size_t position, const std::string& label) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
LayoutModel calculateLayout(int width, int height) {
|
|
||||||
LayoutModel model;
|
|
||||||
model.terminalTooSmall = width < 44 || height < 12;
|
|
||||||
if (model.terminalTooSmall) {
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
model.contentWidth = static_cast<size_t>(std::max(8, width - 4));
|
|
||||||
model.spectrumRowCount = static_cast<size_t>(std::max(2, height - 10));
|
|
||||||
model.meterWidth = static_cast<size_t>(std::max(8, width - 19));
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<float> projectSpectrum(const std::vector<float>& magnitudes,
|
std::vector<float> projectSpectrum(const std::vector<float>& magnitudes,
|
||||||
size_t fftSize,
|
size_t fftSize,
|
||||||
size_t columns,
|
size_t columns,
|
||||||
|
|||||||
@@ -15,15 +15,6 @@ struct SpectrumProjectionOptions {
|
|||||||
float tiltReferenceHz = 1000.0f;
|
float tiltReferenceHz = 1000.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LayoutModel {
|
|
||||||
bool terminalTooSmall = true;
|
|
||||||
size_t contentWidth = 0;
|
|
||||||
size_t spectrumRowCount = 0;
|
|
||||||
size_t meterWidth = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
LayoutModel calculateLayout(int width, int height);
|
|
||||||
|
|
||||||
std::vector<float> projectSpectrum(const std::vector<float>& magnitudes,
|
std::vector<float> projectSpectrum(const std::vector<float>& magnitudes,
|
||||||
size_t fftSize,
|
size_t fftSize,
|
||||||
size_t columns,
|
size_t columns,
|
||||||
|
|||||||
+242
-38
@@ -1,6 +1,7 @@
|
|||||||
#include "tui_runtime.h"
|
#include "tui_runtime.h"
|
||||||
|
|
||||||
#include "analysis_pipeline.h"
|
#include "analysis_pipeline.h"
|
||||||
|
#include "dashboard_layout.h"
|
||||||
#include "display_model.h"
|
#include "display_model.h"
|
||||||
#include "snapshot_store.h"
|
#include "snapshot_store.h"
|
||||||
|
|
||||||
@@ -32,7 +33,6 @@
|
|||||||
namespace Prism::Tui {
|
namespace Prism::Tui {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr size_t kFftSize = 2048;
|
|
||||||
constexpr auto kCapturePollInterval = std::chrono::milliseconds(2);
|
constexpr auto kCapturePollInterval = std::chrono::milliseconds(2);
|
||||||
constexpr auto kDisplayFrameInterval = std::chrono::milliseconds(33);
|
constexpr auto kDisplayFrameInterval = std::chrono::milliseconds(33);
|
||||||
|
|
||||||
@@ -69,72 +69,243 @@ struct DisplayFrame {
|
|||||||
bool captureOverrun = false;
|
bool captureOverrun = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
std::string makeFooter(const DisplayFrame& frame) {
|
struct InterfaceState {
|
||||||
|
PanelId focusedPanel = PanelId::Spectrum;
|
||||||
|
LayoutPreset layoutPreset = LayoutPreset::Automatic;
|
||||||
|
std::optional<PanelId> expandedPanel;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string makeCaptureStatus(const DisplayFrame& frame, bool compact) {
|
||||||
std::ostringstream sampleRate;
|
std::ostringstream sampleRate;
|
||||||
const double kilohertz = frame.sampleRate / 1000.0;
|
const double kilohertz = frame.sampleRate / 1000.0;
|
||||||
sampleRate << std::fixed << std::setprecision(
|
sampleRate << std::fixed << std::setprecision(
|
||||||
std::abs(kilohertz - std::round(kilohertz)) < 0.01 ? 0 : 1) << kilohertz;
|
std::abs(kilohertz - std::round(kilohertz)) < 0.01 ? 0 : 1) << kilohertz;
|
||||||
std::string footer = frame.backend + " • " + frame.device + " • " +
|
std::string footer = frame.backend + " • ";
|
||||||
sampleRate.str() + " kHz";
|
if (!compact) {
|
||||||
|
footer += frame.device + " • ";
|
||||||
|
}
|
||||||
|
footer += sampleRate.str() + " kHz";
|
||||||
if (frame.captureOverrun) {
|
if (frame.captureOverrun) {
|
||||||
footer += " • capture overrun";
|
footer += " • capture overrun";
|
||||||
}
|
}
|
||||||
footer += " r reset • q/Esc/Ctrl-C quit";
|
|
||||||
return footer;
|
return footer;
|
||||||
}
|
}
|
||||||
|
|
||||||
ftxui::Element renderFrame(const DisplayFrame& frame, int width, int height) {
|
std::string panelName(PanelId panel) {
|
||||||
using namespace ftxui;
|
switch (panel) {
|
||||||
const auto layout = calculateLayout(width, height);
|
case PanelId::Spectrum:
|
||||||
if (layout.terminalTooSmall) {
|
return "Spectrum";
|
||||||
return vbox({
|
case PanelId::Levels:
|
||||||
filler(),
|
return "Levels";
|
||||||
text("Prism TUI") | bold | center,
|
|
||||||
text("Terminal too small — need at least 44 × 12") | center,
|
|
||||||
text("q quit") | dim | center,
|
|
||||||
filler(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return "Panel";
|
||||||
|
}
|
||||||
|
|
||||||
|
ftxui::Element panelTitle(PanelId panel, bool focused) {
|
||||||
|
using namespace ftxui;
|
||||||
|
const std::string number = panel == PanelId::Spectrum ? "1" : "2";
|
||||||
|
std::string label = " " + number + " " + panelName(panel);
|
||||||
|
if (panel == PanelId::Spectrum) {
|
||||||
|
label += " • FFT " + std::to_string(kDefaultFftSize);
|
||||||
|
}
|
||||||
|
label += " ";
|
||||||
|
auto title = text(label);
|
||||||
|
return focused
|
||||||
|
? title | color(Color::CyanLight) | bold
|
||||||
|
: title | color(Color::GrayDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
ftxui::Element stylePanel(ftxui::Element content, bool focused) {
|
||||||
|
using namespace ftxui;
|
||||||
|
return content | color(focused ? Color::GrayLight : Color::GrayDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
bool focused) {
|
||||||
|
using namespace ftxui;
|
||||||
|
const size_t contentWidth = static_cast<size_t>(std::max(1, width - 2));
|
||||||
|
const size_t contentHeight = static_cast<size_t>(std::max(1, height - 2));
|
||||||
|
const size_t spectrumRows = contentHeight > 1 ? contentHeight - 1 : 1;
|
||||||
|
|
||||||
SpectrumProjectionOptions projectionOptions;
|
SpectrumProjectionOptions projectionOptions;
|
||||||
projectionOptions.sampleRate = static_cast<float>(frame.sampleRate);
|
projectionOptions.sampleRate = static_cast<float>(frame.sampleRate);
|
||||||
projectionOptions.maxFrequency = std::min(20000.0f, projectionOptions.sampleRate * 0.5f);
|
projectionOptions.maxFrequency = std::min(20000.0f, projectionOptions.sampleRate * 0.5f);
|
||||||
const auto projected = projectSpectrum(
|
const auto projected = projectSpectrum(
|
||||||
frame.magnitudes,
|
frame.magnitudes,
|
||||||
kFftSize,
|
kDefaultFftSize,
|
||||||
layout.contentWidth,
|
contentWidth,
|
||||||
projectionOptions);
|
projectionOptions);
|
||||||
const auto spectrumRows = buildSpectrumRows(projected, layout.spectrumRowCount);
|
const auto rows = buildSpectrumRows(projected, spectrumRows);
|
||||||
|
|
||||||
Elements spectrumElements;
|
Elements spectrumElements;
|
||||||
spectrumElements.reserve(spectrumRows.size() + 1);
|
spectrumElements.reserve(rows.size() + 1);
|
||||||
for (const auto& row : spectrumRows) {
|
for (const auto& row : rows) {
|
||||||
spectrumElements.push_back(text(row) | color(Color::Cyan));
|
spectrumElements.push_back(text(row) | color(Color::Cyan));
|
||||||
}
|
}
|
||||||
spectrumElements.push_back(
|
if (contentHeight > 1) {
|
||||||
text(buildFrequencyAxis(layout.contentWidth, projectionOptions.maxFrequency)) | dim);
|
spectrumElements.push_back(
|
||||||
|
text(buildFrequencyAxis(contentWidth, projectionOptions.maxFrequency)) |
|
||||||
|
color(Color::GrayDark));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto panel = window(
|
||||||
|
panelTitle(PanelId::Spectrum, focused),
|
||||||
|
vbox(std::move(spectrumElements)));
|
||||||
|
return stylePanel(std::move(panel), focused) |
|
||||||
|
size(WIDTH, EQUAL, std::max(1, width)) |
|
||||||
|
size(HEIGHT, EQUAL, std::max(1, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
ftxui::Element renderLevelsPanel(const DisplayFrame& frame,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
bool focused) {
|
||||||
|
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));
|
||||||
|
|
||||||
const auto meterRow = [&](const char* label, float level, float peak) {
|
const auto meterRow = [&](const char* label, float level, float peak) {
|
||||||
return hbox({
|
return hbox({
|
||||||
text(std::string(label) + " ") | bold,
|
text(std::string(label) + " ") | bold,
|
||||||
text(buildMeterBar(level, peak, layout.meterWidth)) | color(Color::Cyan),
|
text(buildMeterBar(level, peak, meterWidth)) | color(Color::Cyan),
|
||||||
text(" " + formatDb(level) + " dB"),
|
text(" " + formatDb(level) + " dB"),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const std::string lufs =
|
Elements body;
|
||||||
"LUFS M " + formatLufs(frame.lufs.momentaryLUFS) +
|
body.push_back(meterRow("L", frame.vu.barLDb, frame.vu.peakLDb));
|
||||||
" S " + formatLufs(frame.lufs.shortTermLUFS) +
|
body.push_back(meterRow("R", frame.vu.barRDb, frame.vu.peakRDb));
|
||||||
" I " + formatLufs(frame.lufs.integratedLUFS);
|
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));
|
||||||
|
} 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));
|
||||||
|
}
|
||||||
|
while (static_cast<int>(body.size()) < contentHeight) {
|
||||||
|
body.push_back(filler());
|
||||||
|
}
|
||||||
|
|
||||||
|
auto panel = window(
|
||||||
|
panelTitle(PanelId::Levels, focused),
|
||||||
|
vbox(std::move(body)));
|
||||||
|
return stylePanel(std::move(panel), focused) |
|
||||||
|
size(WIDTH, EQUAL, std::max(1, width)) |
|
||||||
|
size(HEIGHT, EQUAL, std::max(1, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
const PanelRect* findPanelRect(const DashboardLayout& layout, PanelId panel) {
|
||||||
|
const auto found = std::find_if(
|
||||||
|
layout.panels.begin(), layout.panels.end(),
|
||||||
|
[panel](const PanelRect& rect) { return rect.panel == panel; });
|
||||||
|
return found == layout.panels.end() ? nullptr : &*found;
|
||||||
|
}
|
||||||
|
|
||||||
|
ftxui::Element renderLayoutNode(const LayoutNode& node,
|
||||||
|
const DashboardLayout& layout,
|
||||||
|
const DisplayFrame& frame,
|
||||||
|
PanelId focusedPanel) {
|
||||||
|
using namespace ftxui;
|
||||||
|
if (node.isLeaf()) {
|
||||||
|
const auto* rect = findPanelRect(layout, *node.panel);
|
||||||
|
if (rect == nullptr) {
|
||||||
|
return emptyElement();
|
||||||
|
}
|
||||||
|
const bool focused = *node.panel == focusedPanel;
|
||||||
|
switch (*node.panel) {
|
||||||
|
case PanelId::Spectrum:
|
||||||
|
return renderSpectrumPanel(frame, rect->width, rect->height, focused);
|
||||||
|
case PanelId::Levels:
|
||||||
|
return renderLevelsPanel(frame, rect->width, rect->height, focused);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Elements children;
|
||||||
|
children.reserve(node.children.size());
|
||||||
|
for (const auto& child : node.children) {
|
||||||
|
children.push_back(renderLayoutNode(child, layout, frame, focusedPanel));
|
||||||
|
}
|
||||||
|
return node.axis == SplitAxis::Columns
|
||||||
|
? hbox(std::move(children))
|
||||||
|
: vbox(std::move(children));
|
||||||
|
}
|
||||||
|
|
||||||
|
ftxui::Element renderHeader(const DashboardLayout& layout,
|
||||||
|
const InterfaceState& state) {
|
||||||
|
using namespace ftxui;
|
||||||
|
std::string layoutName = layoutPresetName(state.layoutPreset);
|
||||||
|
if (state.layoutPreset == LayoutPreset::Automatic) {
|
||||||
|
layoutName += "→" + layoutPresetName(layout.resolvedPreset);
|
||||||
|
}
|
||||||
|
return hbox({
|
||||||
|
text(" PRISM") | color(Color::CyanLight) | bold,
|
||||||
|
text(" TUI") | bold,
|
||||||
|
filler(),
|
||||||
|
state.expandedPanel
|
||||||
|
? text("FOCUS • " + panelName(*state.expandedPanel) + " ") | color(Color::CyanLight)
|
||||||
|
: text(layoutName + " ") | dim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ftxui::Element renderFooter(const DisplayFrame& frame,
|
||||||
|
const InterfaceState& state,
|
||||||
|
int width) {
|
||||||
|
using namespace ftxui;
|
||||||
|
const bool compact = width < 108;
|
||||||
|
const bool minimal = width < 64;
|
||||||
|
const std::string enterAction = state.expandedPanel ? "restore" : "expand";
|
||||||
|
const std::string controls = minimal
|
||||||
|
? "Tab • Enter • l • q"
|
||||||
|
: compact
|
||||||
|
? "Tab focus • Enter " + enterAction + " • l layout • q quit"
|
||||||
|
: "Tab focus • Enter " + enterAction + " • l layout • r reset • q quit";
|
||||||
|
auto status = text(makeCaptureStatus(frame, compact)) | dim;
|
||||||
|
if (frame.captureOverrun) {
|
||||||
|
status = status | color(Color::RedLight);
|
||||||
|
}
|
||||||
|
return hbox({
|
||||||
|
status,
|
||||||
|
filler(),
|
||||||
|
text(controls) | dim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ftxui::Element renderFrame(const DisplayFrame& frame,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
const InterfaceState& state) {
|
||||||
|
using namespace ftxui;
|
||||||
|
const auto layout = buildDashboardLayout(
|
||||||
|
width, height, state.layoutPreset, state.expandedPanel);
|
||||||
|
if (layout.terminalTooSmall) {
|
||||||
|
return vbox({
|
||||||
|
filler(),
|
||||||
|
text("PRISM TUI") | bold | color(Color::CyanLight) | center,
|
||||||
|
text("Terminal too small — need at least 44 × 12") | center,
|
||||||
|
text("q quit") | dim | center,
|
||||||
|
filler(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return vbox({
|
return vbox({
|
||||||
text("PRISM TUI") | bold | center,
|
renderHeader(layout, state) | size(HEIGHT, EQUAL, 1),
|
||||||
window(text(" Spectrum ") | bold, vbox(std::move(spectrumElements))) | flex,
|
renderLayoutNode(layout.root, layout, frame, state.focusedPanel),
|
||||||
meterRow("L", frame.vu.barLDb, frame.vu.peakLDb),
|
renderFooter(frame, state, width) | size(HEIGHT, EQUAL, 1),
|
||||||
meterRow("R", frame.vu.barRDb, frame.vu.peakRDb),
|
|
||||||
text(lufs) | color(Color::Yellow),
|
|
||||||
separator(),
|
|
||||||
text(makeFooter(frame)) | dim,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +327,9 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
|
|
||||||
ScreenInteractive screen = ScreenInteractive::Fullscreen();
|
ScreenInteractive screen = ScreenInteractive::Fullscreen();
|
||||||
SnapshotStore<DisplayFrame> frameStore;
|
SnapshotStore<DisplayFrame> frameStore;
|
||||||
|
InterfaceState interfaceState;
|
||||||
DisplayFrame initial;
|
DisplayFrame initial;
|
||||||
initial.magnitudes.assign(kFftSize / 2, -100.0f);
|
initial.magnitudes.assign(kDefaultFftSize / 2, -100.0f);
|
||||||
initial.sampleRate = started.sampleRate;
|
initial.sampleRate = started.sampleRate;
|
||||||
initial.backend = capture->backendName();
|
initial.backend = capture->backendName();
|
||||||
initial.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel;
|
initial.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel;
|
||||||
@@ -170,7 +342,7 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
|
|
||||||
std::thread worker([&]() {
|
std::thread worker([&]() {
|
||||||
try {
|
try {
|
||||||
AnalysisPipeline pipeline(static_cast<float>(started.sampleRate), kFftSize);
|
AnalysisPipeline pipeline(static_cast<float>(started.sampleRate));
|
||||||
|
|
||||||
bool captureOverrun = false;
|
bool captureOverrun = false;
|
||||||
auto nextFrameAt = std::chrono::steady_clock::now();
|
auto nextFrameAt = std::chrono::steady_clock::now();
|
||||||
@@ -213,7 +385,8 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
});
|
});
|
||||||
|
|
||||||
auto renderer = Renderer([&]() {
|
auto renderer = Renderer([&]() {
|
||||||
return renderFrame(frameStore.read(), screen.dimx(), screen.dimy());
|
return renderFrame(
|
||||||
|
frameStore.read(), screen.dimx(), screen.dimy(), interfaceState);
|
||||||
});
|
});
|
||||||
auto component = CatchEvent(renderer, [&](Event event) {
|
auto component = CatchEvent(renderer, [&](Event event) {
|
||||||
if (event == Event::Character('q') || event == Event::Escape || event == Event::CtrlC) {
|
if (event == Event::Character('q') || event == Event::Escape || event == Event::CtrlC) {
|
||||||
@@ -225,6 +398,37 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
resetRequested.store(true);
|
resetRequested.store(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (event == Event::Tab || event == Event::TabReverse) {
|
||||||
|
interfaceState.focusedPanel = nextPanel(
|
||||||
|
interfaceState.focusedPanel,
|
||||||
|
event == Event::TabReverse);
|
||||||
|
if (interfaceState.expandedPanel) {
|
||||||
|
interfaceState.expandedPanel = interfaceState.focusedPanel;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event == Event::Return) {
|
||||||
|
if (interfaceState.expandedPanel) {
|
||||||
|
interfaceState.expandedPanel.reset();
|
||||||
|
} else {
|
||||||
|
interfaceState.expandedPanel = interfaceState.focusedPanel;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event == Event::Character('l')) {
|
||||||
|
interfaceState.layoutPreset = nextLayoutPreset(interfaceState.layoutPreset);
|
||||||
|
interfaceState.expandedPanel.reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event == Event::Character('1') || event == Event::Character('2')) {
|
||||||
|
interfaceState.focusedPanel = event == Event::Character('1')
|
||||||
|
? PanelId::Spectrum
|
||||||
|
: PanelId::Levels;
|
||||||
|
if (interfaceState.expandedPanel) {
|
||||||
|
interfaceState.expandedPanel = interfaceState.focusedPanel;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+53
-9
@@ -1,5 +1,6 @@
|
|||||||
#include "analysis_pipeline.h"
|
#include "analysis_pipeline.h"
|
||||||
#include "cli.h"
|
#include "cli.h"
|
||||||
|
#include "dashboard_layout.h"
|
||||||
#include "display_model.h"
|
#include "display_model.h"
|
||||||
#include "snapshot_store.h"
|
#include "snapshot_store.h"
|
||||||
#include "system_audio_capture.h"
|
#include "system_audio_capture.h"
|
||||||
@@ -104,6 +105,8 @@ void testCli() {
|
|||||||
"duplicate device options should fail");
|
"duplicate device options should fail");
|
||||||
require(!Prism::Tui::parseArguments({"--help", "--version"}).ok,
|
require(!Prism::Tui::parseArguments({"--help", "--version"}).ok,
|
||||||
"exclusive commands should not combine");
|
"exclusive commands should not combine");
|
||||||
|
require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos,
|
||||||
|
"help should describe dashboard keyboard controls");
|
||||||
}
|
}
|
||||||
|
|
||||||
void testProjectionAndLayout() {
|
void testProjectionAndLayout() {
|
||||||
@@ -127,8 +130,12 @@ void testProjectionAndLayout() {
|
|||||||
std::cerr << "Projected 1 kHz peak column: " << peak << '\n';
|
std::cerr << "Projected 1 kHz peak column: " << peak << '\n';
|
||||||
}
|
}
|
||||||
require(peak > 55 && peak < 75, "1 kHz peak should land in the logarithmic center region");
|
require(peak > 55 && peak < 75, "1 kHz peak should land in the logarithmic center region");
|
||||||
require(Prism::Tui::buildSpectrumRows(projected, 6).size() == 6,
|
const auto blockRows = Prism::Tui::buildSpectrumRows(projected, 6);
|
||||||
|
require(blockRows.size() == 6,
|
||||||
"spectrum rows should follow the requested height");
|
"spectrum rows should follow the requested height");
|
||||||
|
require(std::any_of(blockRows.begin(), blockRows.end(), [](const std::string& row) {
|
||||||
|
return row.find("█") != std::string::npos;
|
||||||
|
}), "spectrum should retain its solid block fill style");
|
||||||
require(Prism::Tui::buildSpectrumRows(projected, 0).empty(),
|
require(Prism::Tui::buildSpectrumRows(projected, 0).empty(),
|
||||||
"zero-height spectrum should be empty");
|
"zero-height spectrum should be empty");
|
||||||
const auto meter = Prism::Tui::buildMeterBar(-12.0f, -6.0f, 20);
|
const auto meter = Prism::Tui::buildMeterBar(-12.0f, -6.0f, 20);
|
||||||
@@ -137,16 +144,49 @@ void testProjectionAndLayout() {
|
|||||||
require(meter.find("│") != std::string::npos,
|
require(meter.find("│") != std::string::npos,
|
||||||
"meter bar should include its peak marker");
|
"meter bar should include its peak marker");
|
||||||
|
|
||||||
const auto normal = Prism::Tui::calculateLayout(100, 30);
|
const auto wide = Prism::Tui::buildDashboardLayout(
|
||||||
const auto narrow = Prism::Tui::calculateLayout(44, 12);
|
100, 30, Prism::Tui::LayoutPreset::Automatic);
|
||||||
require(!normal.terminalTooSmall && normal.spectrumRowCount == 20,
|
require(!wide.terminalTooSmall &&
|
||||||
"normal terminal layout should fill available height");
|
wide.resolvedPreset == Prism::Tui::LayoutPreset::Columns,
|
||||||
require(!narrow.terminalTooSmall && narrow.contentWidth == 40,
|
"wide, tall terminals should use the columns dashboard");
|
||||||
"minimum terminal layout should remain renderable");
|
require(wide.panels.size() == 2 &&
|
||||||
require(Prism::Tui::calculateLayout(43, 12).terminalTooSmall,
|
wide.panels[0].width + wide.panels[1].width == 100 &&
|
||||||
|
wide.panels[0].width > wide.panels[1].width,
|
||||||
|
"column panels should fill the width and favor the spectrum");
|
||||||
|
|
||||||
|
const auto stacked = Prism::Tui::buildDashboardLayout(
|
||||||
|
80, 20, Prism::Tui::LayoutPreset::Automatic);
|
||||||
|
require(stacked.resolvedPreset == Prism::Tui::LayoutPreset::Stacked,
|
||||||
|
"short terminals should stack their panels");
|
||||||
|
require(stacked.panels.size() == 2 &&
|
||||||
|
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");
|
||||||
|
|
||||||
|
const auto minimum = Prism::Tui::buildDashboardLayout(
|
||||||
|
44, 12, Prism::Tui::LayoutPreset::Automatic);
|
||||||
|
require(!minimum.terminalTooSmall && minimum.panels.size() == 2 &&
|
||||||
|
minimum.panels[0].height == 5 && minimum.panels[1].height == 5,
|
||||||
|
"minimum terminal layout should keep both panels usable");
|
||||||
|
require(Prism::Tui::buildDashboardLayout(
|
||||||
|
43, 12, Prism::Tui::LayoutPreset::Automatic).terminalTooSmall,
|
||||||
"narrow resize should select the compact screen");
|
"narrow resize should select the compact screen");
|
||||||
require(Prism::Tui::calculateLayout(80, 11).terminalTooSmall,
|
require(Prism::Tui::buildDashboardLayout(
|
||||||
|
80, 11, Prism::Tui::LayoutPreset::Automatic).terminalTooSmall,
|
||||||
"short resize should select the compact screen");
|
"short resize should select the compact screen");
|
||||||
|
|
||||||
|
const auto expanded = Prism::Tui::buildDashboardLayout(
|
||||||
|
100, 30, Prism::Tui::LayoutPreset::Columns, Prism::Tui::PanelId::Levels);
|
||||||
|
require(expanded.panels.size() == 1 &&
|
||||||
|
expanded.panels[0].panel == Prism::Tui::PanelId::Levels &&
|
||||||
|
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::Levels,
|
||||||
|
"panel focus should cycle forward");
|
||||||
|
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) ==
|
||||||
|
Prism::Tui::PanelId::Levels,
|
||||||
|
"panel focus should cycle backward");
|
||||||
}
|
}
|
||||||
|
|
||||||
void testPipelineAndFakeCapture() {
|
void testPipelineAndFakeCapture() {
|
||||||
@@ -160,6 +200,8 @@ void testPipelineAndFakeCapture() {
|
|||||||
capture.chunks.push_back(sineChunk(1000.0f, 0.25f, 2400, 48000.0f));
|
capture.chunks.push_back(sineChunk(1000.0f, 0.25f, 2400, 48000.0f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require(Prism::Tui::kDefaultFftSize == 4096,
|
||||||
|
"the TUI spectrum should default to a 4096-point FFT");
|
||||||
Prism::Tui::AnalysisPipeline pipeline(48000.0f);
|
Prism::Tui::AnalysisPipeline pipeline(48000.0f);
|
||||||
bool captureOverrun = false;
|
bool captureOverrun = false;
|
||||||
capture.nextOverwriteCount = 3;
|
capture.nextOverwriteCount = 3;
|
||||||
@@ -172,6 +214,8 @@ void testPipelineAndFakeCapture() {
|
|||||||
worker.join();
|
worker.join();
|
||||||
require(captureOverrun, "capture draining should publish queue overruns");
|
require(captureOverrun, "capture draining should publish queue overruns");
|
||||||
const auto frame = pipeline.snapshot();
|
const auto frame = pipeline.snapshot();
|
||||||
|
require(frame.magnitudes.size() == Prism::Tui::kDefaultFftSize / 2,
|
||||||
|
"the default analysis pipeline should publish the 4096-point spectrum");
|
||||||
require(frame.vu.barLDb > -20.0f && frame.vu.barLDb < -5.0f,
|
require(frame.vu.barLDb > -20.0f && frame.vu.barLDb < -5.0f,
|
||||||
"VU level should reflect deterministic input");
|
"VU level should reflect deterministic input");
|
||||||
require(std::isfinite(frame.lufs.momentaryLUFS) && frame.lufs.momentaryLUFS > -60.0f,
|
require(std::isfinite(frame.lufs.momentaryLUFS) && frame.lufs.momentaryLUFS > -60.0f,
|
||||||
|
|||||||
Reference in New Issue
Block a user