settings, toggles, and more

This commit is contained in:
Boof2015
2026-08-14 19:02:26 -04:00
parent e712421bad
commit c45451476d
17 changed files with 2144 additions and 66 deletions
+6 -10
View File
@@ -51,9 +51,7 @@ Capture-to-display latency measures under 8ms. When tested at 120fps, measured l
Installable Prism packages also provide `prism-tui`, a native terminal frontend
for the shared C++ capture and analysis engine. It shows a responsive spectrum,
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.
stereo VU meters, and momentary, short-term, and integrated LUFS readings.
```bash
prism-tui # Capture the default system output
@@ -63,13 +61,11 @@ prism-tui --help
prism-tui --version
```
Press Tab or Shift-Tab to focus a panel, Enter to expand or restore it, and `l`
to cycle automatic, stacked, and column layouts. Number keys `1` and `2` focus
Spectrum and Levels. Press `r` to reset the analyzers and integrated loudness,
or `q`, Escape, or Ctrl-C to quit. Interactive mode requires a terminal of at
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`.
Press `r` to reset the analyzers and integrated loudness, or `q`, Escape, or
Ctrl-C to quit. Interactive mode requires a terminal of at 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
profiles and themes, file/stdin analysis, and the other visualizers remain GUI
+7
View File
@@ -12,6 +12,7 @@ Oscilloscope::Oscilloscope()
, lastFilterPitch_(200.0f)
, lastTrigger_(0)
, smoothedPitch_(200.0f)
, latestDetectedPitch_(0.0f)
, pitchSamplesProcessed_(0) {
// Initialize circular buffers
@@ -144,6 +145,10 @@ OscilloscopeResult Oscilloscope::process() {
result.detectedPitch = smoothedPitch_;
if (!pitchLock_) {
const size_t samples = static_cast<size_t>(displaySamples_);
result.triggerIndex = static_cast<float>(
(writePos_ + OSCILLOSCOPE_BUFFER_SIZE - samples) %
OSCILLOSCOPE_BUFFER_SIZE);
return result;
}
@@ -172,6 +177,7 @@ OscilloscopeResult Oscilloscope::process() {
float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f);
if (newPitch > 0.0f) {
latestDetectedPitch_ = newPitch;
pitchSamplesProcessed_++;
// Adaptive smoothing: fast convergence initially, then conservative
@@ -307,6 +313,7 @@ void Oscilloscope::reset() {
writePos_ = 0;
lastTrigger_ = 0.0f;
smoothedPitch_ = 200.0f;
latestDetectedPitch_ = 0.0f;
lastFilterPitch_ = 200.0f;
pitchSamplesProcessed_ = 0; // Reset warmup counter for fast convergence on next use
+5
View File
@@ -36,6 +36,10 @@ public:
// Get current write position
size_t getWritePos() const { return writePos_; }
// Latest unsmoothed detector result. The stable detectedPitch returned by
// process() remains the value used for pitch-locked triggering.
float getLatestDetectedPitch() const { return latestDetectedPitch_; }
// Get samples from circular buffer (for rendering)
void getSamples(float* output, size_t startPos, size_t count) const;
@@ -75,6 +79,7 @@ private:
float lastTrigger_;
float smoothedPitch_;
float latestDetectedPitch_;
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
// Internal helpers
+6
View File
@@ -33,7 +33,13 @@ add_library(prism_tui_analysis STATIC
src/cli.cpp
src/dashboard_layout.cpp
src/display_model.cpp
src/scope_plot_model.cpp
src/spectrum_peak_model.cpp
src/tui_settings.cpp
${PRISM_NATIVE_DIR}/spectrum.cpp
${PRISM_NATIVE_DIR}/oscilloscope.cpp
${PRISM_NATIVE_DIR}/vectorscope.cpp
${PRISM_NATIVE_DIR}/multiband.cpp
${PRISM_NATIVE_DIR}/vumeter.cpp
${PRISM_NATIVE_DIR}/lufsmeter.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
+106 -9
View File
@@ -1,15 +1,39 @@
#include "analysis_pipeline.h"
#include <algorithm>
#include <cmath>
namespace Prism::Tui {
namespace {
int normalizedOscilloscopeDisplaySamples(float sampleRate) {
constexpr int baseSamples = 2048;
constexpr float baseRateMin = 44100.0f;
constexpr float baseRateMax = 48000.0f;
float samples = static_cast<float>(baseSamples);
if (sampleRate > 0.0f && sampleRate < baseRateMin) {
samples *= sampleRate / baseRateMin;
} else if (sampleRate > baseRateMax) {
samples *= sampleRate / baseRateMax;
}
return std::clamp(
static_cast<int>(std::lround(samples)),
64,
static_cast<int>(Visualizer::OSCILLOSCOPE_BUFFER_SIZE - 1));
}
} // namespace
AnalysisPipeline::AnalysisPipeline(float sampleRate, size_t fftSize)
: spectrum_(fftSize) {
: spectrum_(fftSize), sampleRate_(sampleRate), fftSize_(fftSize) {
spectrum_.setSampleRate(sampleRate);
spectrum_.setSmoothing(0.9f);
vu_.setSampleRate(sampleRate);
lufs_.setSampleRate(sampleRate);
oscilloscope_.setSampleRate(sampleRate);
oscilloscope_.setPitchLock(true);
oscilloscope_.setDisplaySamples(normalizedOscilloscopeDisplaySamples(sampleRate));
vectorscope_.setSampleRate(sampleRate);
}
void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) {
@@ -17,23 +41,96 @@ void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) {
if (count == 0) {
return;
}
spectrum_.pushStereoSamples(chunk.left.data(), chunk.right.data(), count);
vu_.pushSamples(chunk.left.data(), chunk.right.data(), count);
lufs_.pushSamples(chunk.left.data(), chunk.right.data(), count);
const float* left = chunk.left.data();
const float* right = chunk.right.data();
if (inputGainLinear_ != 1.0f) {
trimmedLeftScratch_.resize(count);
trimmedRightScratch_.resize(count);
for (size_t index = 0; index < count; ++index) {
trimmedLeftScratch_[index] = chunk.left[index] * inputGainLinear_;
trimmedRightScratch_[index] = chunk.right[index] * inputGainLinear_;
}
left = trimmedLeftScratch_.data();
right = trimmedRightScratch_.data();
}
spectrum_.pushStereoSamples(left, right, count);
vu_.pushSamples(left, right, count);
lufs_.pushSamples(left, right, count);
monoScratch_.resize(count);
for (size_t index = 0; index < count; ++index) {
monoScratch_[index] = (left[index] + right[index]) * 0.5f;
}
oscilloscope_.pushSamples(monoScratch_.data(), count);
vectorscope_.pushMultibandSamples(left, right, count);
}
AnalysisFrame AnalysisPipeline::snapshot() {
return {
spectrum_.getChannelMaxMagnitudes(),
vu_.getSnapshot(),
lufs_.getSnapshot(),
};
AnalysisFrame frame;
frame.magnitudes = spectrum_.getChannelMaxMagnitudes();
frame.spectrumPeak = spectrumPeakTracker_.select(
frame.magnitudes, sampleRate_, fftSize_, spectrumTiltDbPerOctave_);
frame.vu = vu_.getSnapshot();
frame.lufs = lufs_.getSnapshot();
const auto oscilloscopeResult = oscilloscope_.process();
const size_t oscilloscopeSamples = static_cast<size_t>(
std::max(0, oscilloscopeResult.samplesToShow));
frame.oscilloscope.samples.resize(oscilloscopeSamples);
if (!frame.oscilloscope.samples.empty()) {
oscilloscope_.getSamplesInterpolated(
frame.oscilloscope.samples.data(),
oscilloscopeResult.triggerIndex,
frame.oscilloscope.samples.size());
frame.oscilloscope.signalPresent = std::any_of(
frame.oscilloscope.samples.begin(),
frame.oscilloscope.samples.end(),
[](float sample) { return std::isfinite(sample) && std::abs(sample) > 0.001f; });
}
const float latestPitch = oscilloscope_.getLatestDetectedPitch();
if (std::isfinite(latestPitch) && latestPitch > 0.0f) {
constexpr float previousWeight = 0.6f;
displayPitch_ = displayPitch_ > 0.0f
? displayPitch_ * previousWeight + latestPitch * (1.0f - previousWeight)
: latestPitch;
}
frame.oscilloscope.detectedPitch = displayPitch_;
frame.vectorscope.multibandPoints.resize(
kVectorscopeDisplayPoints * Visualizer::MULTIBAND_POINT_STRIDE);
frame.vectorscope.pointCount = vectorscope_.getMultibandPoints(
frame.vectorscope.multibandPoints.data(),
kVectorscopeDisplayPoints);
frame.vectorscope.multibandPoints.resize(
frame.vectorscope.pointCount * Visualizer::MULTIBAND_POINT_STRIDE);
return frame;
}
void AnalysisPipeline::reset() {
spectrum_.reset();
vu_.reset();
lufs_.reset();
oscilloscope_.reset();
vectorscope_.reset();
spectrumPeakTracker_.reset();
monoScratch_.clear();
trimmedLeftScratch_.clear();
trimmedRightScratch_.clear();
displayPitch_ = 0.0f;
}
void AnalysisPipeline::setInputTrimDb(float db) {
const float normalized = std::clamp(
std::isfinite(db) ? db : 0.0f, -12.0f, 12.0f);
inputGainLinear_ = std::pow(10.0f, normalized / 20.0f);
}
void AnalysisPipeline::setSpectrumTilt(float dbPerOctave) {
spectrumTiltDbPerOctave_ = std::clamp(
std::isfinite(dbPerOctave) ? dbPerOctave : 2.0f, -2.0f, 8.0f);
}
void AnalysisPipeline::setOscilloscopePitchLock(bool enabled) {
oscilloscope_.setPitchLock(enabled);
}
size_t drainCapture(Prism::Capture::SystemAudioCapture& capture,
+33
View File
@@ -1,20 +1,39 @@
#pragma once
#include "lufsmeter.h"
#include "oscilloscope.h"
#include "spectrum.h"
#include "spectrum_peak_model.h"
#include "system_audio_capture.h"
#include "vectorscope.h"
#include "vumeter.h"
#include <vector>
#include <optional>
namespace Prism::Tui {
constexpr size_t kDefaultFftSize = 4096;
constexpr size_t kVectorscopeDisplayPoints = 4096;
struct OscilloscopeFrame {
std::vector<float> samples;
float detectedPitch = 0.0f;
bool signalPresent = false;
};
struct VectorscopeFrame {
std::vector<float> multibandPoints;
size_t pointCount = 0;
};
struct AnalysisFrame {
std::vector<float> magnitudes;
std::optional<SpectrumPeakInfo> spectrumPeak;
Visualizer::VUMeterSnapshot vu{};
Visualizer::LUFSMeterSnapshot lufs{};
OscilloscopeFrame oscilloscope;
VectorscopeFrame vectorscope;
};
class AnalysisPipeline {
@@ -24,11 +43,25 @@ public:
void process(const Prism::Capture::AudioChunk& chunk);
AnalysisFrame snapshot();
void reset();
void setInputTrimDb(float db);
void setSpectrumTilt(float dbPerOctave);
void setOscilloscopePitchLock(bool enabled);
private:
Visualizer::Spectrum spectrum_;
Visualizer::VUMeterAnalyzer vu_;
Visualizer::LUFSMeterAnalyzer lufs_;
Visualizer::Oscilloscope oscilloscope_;
Visualizer::Vectorscope vectorscope_;
SpectrumPeakTracker spectrumPeakTracker_;
std::vector<float> monoScratch_;
std::vector<float> trimmedLeftScratch_;
std::vector<float> trimmedRightScratch_;
float sampleRate_ = 48000.0f;
size_t fftSize_ = kDefaultFftSize;
float inputGainLinear_ = 1.0f;
float spectrumTiltDbPerOctave_ = 2.0f;
float displayPitch_ = 0.0f;
};
size_t drainCapture(Prism::Capture::SystemAudioCapture& capture,
+3 -1
View File
@@ -60,8 +60,10 @@ std::string usageText() {
"Controls:\n"
" Tab / Shift-Tab Focus the next or previous panel.\n"
" Enter Expand the focused panel or restore the dashboard.\n"
" s Open settings for the focused scope.\n"
" l Cycle automatic, stacked, and column layouts.\n"
" 1 / 2 Focus Spectrum or Levels.\n"
" v Cycle vectorscope display modes.\n"
" 1 / 2 / 3 / 4 Focus Spectrum, Oscilloscope, Vectorscope, or Levels.\n"
" r Reset analyzers and integrated loudness.\n"
" q / Esc / Ctrl-C Quit.\n";
}
+52 -9
View File
@@ -16,6 +16,10 @@ MinimumSize panelMinimumSize(PanelId panel) {
switch (panel) {
case PanelId::Spectrum:
return {30, 5};
case PanelId::Oscilloscope:
return {30, 5};
case PanelId::Vectorscope:
return {30, 8};
case PanelId::Levels:
return {30, 5};
}
@@ -134,13 +138,15 @@ void resolveNode(const LayoutNode& node,
LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) {
constexpr int minimumColumnsWidth = 72;
if (requested == LayoutPreset::Columns && width < minimumColumnsWidth) {
constexpr int minimumColumnsHeight = 18;
if (requested == LayoutPreset::Columns &&
(width < minimumColumnsWidth || height < minimumColumnsHeight)) {
return LayoutPreset::Stacked;
}
if (requested != LayoutPreset::Automatic) {
return requested;
}
return width >= 96 && height >= 28
return width >= minimumColumnsWidth && height >= minimumColumnsHeight
? LayoutPreset::Columns
: LayoutPreset::Stacked;
}
@@ -148,8 +154,14 @@ LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) {
LayoutNode makeRoot(LayoutPreset preset) {
if (preset == LayoutPreset::Columns) {
return LayoutNode::split(SplitAxis::Columns, {
LayoutNode::leaf(PanelId::Spectrum, 4),
LayoutNode::leaf(PanelId::Levels, 1),
LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Spectrum, 3),
LayoutNode::leaf(PanelId::Oscilloscope, 2),
}, 3),
LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Vectorscope, 1),
LayoutNode::leaf(PanelId::Levels, 1),
}, 1),
});
}
return LayoutNode::split(SplitAxis::Rows, {
@@ -223,19 +235,50 @@ std::string layoutPresetName(LayoutPreset preset) {
}
std::vector<PanelId> panelOrder() {
return {PanelId::Spectrum, PanelId::Levels};
return {
PanelId::Spectrum,
PanelId::Oscilloscope,
PanelId::Vectorscope,
PanelId::Levels,
};
}
PanelId nextPanel(PanelId panel, bool reverse) {
const auto panels = panelOrder();
return nextPanel(panel, panelOrder(), reverse);
}
PanelId nextPanel(PanelId panel,
const std::vector<PanelId>& panels,
bool reverse) {
if (panels.empty()) {
return panel;
}
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 (found == panels.end()) {
return reverse ? panels.back() : panels.front();
}
const size_t index = 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()];
}
std::vector<PanelId> visiblePanelOrder(const DashboardLayout& layout) {
std::vector<PanelId> result;
for (const auto panel : panelOrder()) {
if (layoutContainsPanel(layout, panel)) {
result.push_back(panel);
}
}
return result;
}
bool layoutContainsPanel(const DashboardLayout& layout, PanelId panel) {
return std::any_of(
layout.panels.begin(),
layout.panels.end(),
[panel](const PanelRect& rect) { return rect.panel == panel; });
}
} // namespace Prism::Tui
+7
View File
@@ -8,6 +8,8 @@ namespace Prism::Tui {
enum class PanelId {
Spectrum,
Oscilloscope,
Vectorscope,
Levels,
};
@@ -62,5 +64,10 @@ LayoutPreset nextLayoutPreset(LayoutPreset preset);
std::string layoutPresetName(LayoutPreset preset);
std::vector<PanelId> panelOrder();
PanelId nextPanel(PanelId panel, bool reverse = false);
PanelId nextPanel(PanelId panel,
const std::vector<PanelId>& panels,
bool reverse = false);
std::vector<PanelId> visiblePanelOrder(const DashboardLayout& layout);
bool layoutContainsPanel(const DashboardLayout& layout, PanelId panel);
} // namespace Prism::Tui
+230
View File
@@ -0,0 +1,230 @@
#include "scope_plot_model.h"
#include "multiband.h"
#include <algorithm>
#include <cmath>
namespace Prism::Tui {
namespace {
constexpr float kInverseSqrtTwo = 0.7071067811865475f;
bool isUnipolar(VectorscopeMode mode) {
return mode == VectorscopeMode::PolarUnipolar ||
mode == VectorscopeMode::LinearUnipolar;
}
bool isPolar(VectorscopeMode mode) {
return mode == VectorscopeMode::PolarUnipolar ||
mode == VectorscopeMode::PolarBipolar;
}
bool transformVectorscopePoint(float left,
float right,
VectorscopeMode mode,
float& x,
float& y) {
if (mode == VectorscopeMode::Lissajous) {
x = right;
y = left;
return true;
}
const float mid = (left + right) * kInverseSqrtTwo;
const float side = (right - left) * kInverseSqrtTwo;
if (isUnipolar(mode) && mid < 0.0f) {
return false;
}
if (isPolar(mode)) {
const float amplitudeSquared = mid * mid + side * side;
if (amplitudeSquared < 1e-12f) {
x = 0.0f;
y = 0.0f;
return true;
}
const float amplitude = std::sqrt(amplitudeSquared);
const float scaledAmplitude = std::pow(amplitude, 0.35f);
const float factor = scaledAmplitude / amplitude;
x = side * factor;
y = mid * factor;
return true;
}
x = side;
y = mid;
return true;
}
} // namespace
std::vector<PlotPoint> buildOscilloscopePlot(const std::vector<float>& samples,
int pixelWidth,
int pixelHeight) {
if (samples.empty() || pixelWidth <= 0 || pixelHeight <= 0) {
return {};
}
std::vector<PlotPoint> points;
points.reserve(static_cast<size_t>(pixelWidth));
const float sampleSpan = static_cast<float>(samples.size() - 1);
const float xSpan = static_cast<float>(std::max(1, pixelWidth - 1));
const float ySpan = static_cast<float>(std::max(0, pixelHeight - 1));
for (int x = 0; x < pixelWidth; ++x) {
const float samplePosition = static_cast<float>(x) / xSpan * sampleSpan;
const size_t first = std::min(
samples.size() - 1,
static_cast<size_t>(std::floor(samplePosition)));
const size_t second = std::min(samples.size() - 1, first + 1);
const float fraction = samplePosition - static_cast<float>(first);
const float firstSample = std::isfinite(samples[first]) ? samples[first] : 0.0f;
const float secondSample = std::isfinite(samples[second]) ? samples[second] : 0.0f;
const float sample = std::clamp(
firstSample + (secondSample - firstSample) * fraction,
-1.0f,
1.0f);
const int y = static_cast<int>(std::lround(
(1.0f - sample) * 0.5f * ySpan));
points.push_back({x, std::clamp(y, 0, pixelHeight - 1)});
}
return points;
}
int oscilloscopeZeroY(int pixelHeight) {
if (pixelHeight <= 0) {
return 0;
}
return static_cast<int>(std::lround(
static_cast<float>(pixelHeight - 1) * 0.5f));
}
VectorscopeBands buildVectorscopePlot(const std::vector<float>& multibandPoints,
size_t pointCount,
int pixelWidth,
int pixelHeight,
VectorscopeMode mode,
int densityDivisor) {
VectorscopeBands result;
if (pixelWidth <= 0 || pixelHeight <= 0 || multibandPoints.empty()) {
return result;
}
const size_t count = std::min(
pointCount,
multibandPoints.size() / Visualizer::MULTIBAND_POINT_STRIDE);
const size_t pixelCapacity = static_cast<size_t>(pixelWidth) *
static_cast<size_t>(pixelHeight);
const size_t sampleBudget = std::min(
count,
std::max<size_t>(64, pixelCapacity /
static_cast<size_t>(std::max(1, densityDivisor))));
const size_t stride = sampleBudget > 0
? std::max<size_t>(1, (count + sampleBudget - 1) / sampleBudget)
: 1;
const size_t firstIndex = count > 0 ? (count - 1) % stride : 0;
for (auto& band : result) {
band.reserve(sampleBudget);
}
const auto layout = getVectorscopePlotLayout(pixelWidth, pixelHeight, mode);
for (size_t index = firstIndex; index < count; index += stride) {
const size_t base = index * Visualizer::MULTIBAND_POINT_STRIDE;
const float intensity = count > 1
? 0.15f + 0.85f * static_cast<float>(index) /
static_cast<float>(count - 1)
: 1.0f;
for (size_t band = 0; band < result.size(); ++band) {
const float leftValue = multibandPoints[base + band * 2];
const float rightValue = multibandPoints[base + band * 2 + 1];
const float left = std::isfinite(leftValue)
? std::clamp(leftValue, -1.25f, 1.25f)
: 0.0f;
const float right = std::isfinite(rightValue)
? std::clamp(rightValue, -1.25f, 1.25f)
: 0.0f;
if (std::abs(left) + std::abs(right) < 1e-5f) {
continue;
}
float transformedX = 0.0f;
float transformedY = 0.0f;
if (!transformVectorscopePoint(
left, right, mode, transformedX, transformedY)) {
continue;
}
const int x = static_cast<int>(std::lround(
static_cast<float>(layout.centerX) +
transformedX * static_cast<float>(layout.radius)));
const int y = static_cast<int>(std::lround(
static_cast<float>(layout.centerY) -
transformedY * static_cast<float>(layout.radius)));
result[band].push_back({
std::clamp(x, 0, pixelWidth - 1),
std::clamp(y, 0, pixelHeight - 1),
intensity,
});
}
}
return result;
}
VectorscopePlotLayout getVectorscopePlotLayout(int pixelWidth,
int pixelHeight,
VectorscopeMode mode) {
VectorscopePlotLayout layout;
if (pixelWidth <= 0 || pixelHeight <= 0) {
return layout;
}
layout.centerX = (pixelWidth - 1) / 2;
layout.unipolar = isUnipolar(mode);
if (layout.unipolar) {
const int margin = std::max(1, pixelHeight / 25);
layout.centerY = pixelHeight - 1 - margin;
layout.radius = static_cast<int>(std::lround(
static_cast<float>(std::min(
pixelWidth / 2,
std::max(0, layout.centerY))) * 0.88f));
} else {
layout.centerY = (pixelHeight - 1) / 2;
layout.radius = static_cast<int>(std::lround(
static_cast<float>(std::min(pixelWidth, pixelHeight)) * 0.45f));
}
layout.radius = std::max(0, layout.radius);
return layout;
}
VectorscopeMode nextVectorscopeMode(VectorscopeMode mode) {
switch (mode) {
case VectorscopeMode::Lissajous:
return VectorscopeMode::PolarUnipolar;
case VectorscopeMode::PolarUnipolar:
return VectorscopeMode::PolarBipolar;
case VectorscopeMode::PolarBipolar:
return VectorscopeMode::LinearUnipolar;
case VectorscopeMode::LinearUnipolar:
return VectorscopeMode::LinearBipolar;
case VectorscopeMode::LinearBipolar:
return VectorscopeMode::Lissajous;
}
return VectorscopeMode::Lissajous;
}
const char* vectorscopeModeName(VectorscopeMode mode) {
switch (mode) {
case VectorscopeMode::Lissajous:
return "Lissajous";
case VectorscopeMode::PolarUnipolar:
return "Polar +";
case VectorscopeMode::PolarBipolar:
return "Polar ±";
case VectorscopeMode::LinearUnipolar:
return "Linear +";
case VectorscopeMode::LinearBipolar:
return "Linear ±";
}
return "Lissajous";
}
} // namespace Prism::Tui
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <array>
#include <cstddef>
#include <vector>
namespace Prism::Tui {
struct PlotPoint {
int x = 0;
int y = 0;
float intensity = 1.0f;
};
enum class VectorscopeMode {
Lissajous,
PolarUnipolar,
PolarBipolar,
LinearUnipolar,
LinearBipolar,
};
struct VectorscopePlotLayout {
int centerX = 0;
int centerY = 0;
int radius = 0;
bool unipolar = false;
};
using VectorscopeBands = std::array<std::vector<PlotPoint>, 3>;
std::vector<PlotPoint> buildOscilloscopePlot(const std::vector<float>& samples,
int pixelWidth,
int pixelHeight);
int oscilloscopeZeroY(int pixelHeight);
VectorscopeBands buildVectorscopePlot(const std::vector<float>& multibandPoints,
size_t pointCount,
int pixelWidth,
int pixelHeight,
VectorscopeMode mode = VectorscopeMode::Lissajous,
int densityDivisor = 6);
VectorscopePlotLayout getVectorscopePlotLayout(int pixelWidth,
int pixelHeight,
VectorscopeMode mode);
VectorscopeMode nextVectorscopeMode(VectorscopeMode mode);
const char* vectorscopeModeName(VectorscopeMode mode);
} // namespace Prism::Tui
+178
View File
@@ -0,0 +1,178 @@
#include "spectrum_peak_model.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <iomanip>
#include <limits>
#include <sstream>
namespace Prism::Tui {
namespace {
constexpr float kMinFrequency = 20.0f;
constexpr float kMaxFrequency = 20000.0f;
constexpr float kTiltReferenceHz = 1000.0f;
constexpr float kMaximumStickyDistanceOctaves = 0.5f;
constexpr float kSwitchThresholdDb = 4.0f;
constexpr float kLowFrequencyBiasDbPerOctave = 0.75f;
constexpr float kUpwardSwitchThresholdDb = 2.0f;
constexpr float kSilenceThresholdDbfs = -90.0f;
float finiteMagnitude(const std::vector<float>& magnitudes, size_t index) {
return std::isfinite(magnitudes[index]) ? magnitudes[index] : -120.0f;
}
float frequencyForBin(float bin, float sampleRate, size_t fftSize) {
return bin * sampleRate / static_cast<float>(fftSize);
}
float displayDb(float dbfs, float frequencyHz, float tiltDbPerOctave) {
return dbfs + tiltDbPerOctave * std::log2(
std::max(1.0f, frequencyHz) / kTiltReferenceHz);
}
float score(float dbfs, float frequencyHz, float tiltDbPerOctave) {
const float tilted = displayDb(dbfs, frequencyHz, tiltDbPerOctave);
const float octaveOffset = std::max(
0.0f, std::log2(std::max(1.0f, frequencyHz) / kMinFrequency));
return tilted - octaveOffset * kLowFrequencyBiasDbPerOctave;
}
SpectrumPeakInfo peakAt(const std::vector<float>& magnitudes,
size_t bin,
float sampleRate,
size_t fftSize) {
float offset = 0.0f;
float dbfs = finiteMagnitude(magnitudes, bin);
if (bin > 0 && bin + 1 < magnitudes.size()) {
const float previous = finiteMagnitude(magnitudes, bin - 1);
const float current = dbfs;
const float next = finiteMagnitude(magnitudes, bin + 1);
const float denominator = previous - 2.0f * current + next;
if (std::abs(denominator) > 1.0e-9f) {
offset = std::clamp(
0.5f * (previous - next) / denominator, -0.5f, 0.5f);
dbfs = current - 0.25f * (previous - next) * offset;
}
}
const float frequency = frequencyForBin(
static_cast<float>(bin) + offset, sampleRate, fftSize);
return {dbfs, frequency, formatSpectrumPitch(frequency)};
}
} // namespace
std::string formatSpectrumPitch(float frequencyHz) {
if (!std::isfinite(frequencyHz) || frequencyHz <= 0.0f) {
return "--";
}
static constexpr std::array<const char*, 12> noteNames = {
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
};
const float midi = 69.0f + 12.0f * std::log2(frequencyHz / 440.0f);
const int nearest = static_cast<int>(std::lround(midi));
const int cents = static_cast<int>(std::lround((midi - nearest) * 100.0f));
const int noteIndex = ((nearest % 12) + 12) % 12;
const int octave = static_cast<int>(std::floor(static_cast<float>(nearest) / 12.0f)) - 1;
std::ostringstream output;
output << noteNames[static_cast<size_t>(noteIndex)] << octave << ' '
<< (cents > 0 ? "+" : "") << cents << 'c';
return output.str();
}
std::optional<SpectrumPeakInfo> SpectrumPeakTracker::select(
const std::vector<float>& magnitudes,
float sampleRate,
size_t fftSize,
float tiltDbPerOctave) {
if (magnitudes.size() < 3 || sampleRate <= 0.0f || fftSize == 0) {
previous_.reset();
return std::nullopt;
}
const float binWidth = sampleRate / static_cast<float>(fftSize);
const size_t firstBin = std::clamp<size_t>(
static_cast<size_t>(std::ceil(kMinFrequency / binWidth)),
1,
magnitudes.size() - 2);
const size_t lastBin = std::clamp<size_t>(
static_cast<size_t>(std::floor(
std::min(kMaxFrequency, sampleRate * 0.5f) / binWidth)),
firstBin,
magnitudes.size() - 2);
std::vector<size_t> candidates;
for (size_t bin = firstBin; bin <= lastBin; ++bin) {
const float previous = finiteMagnitude(magnitudes, bin - 1);
const float current = finiteMagnitude(magnitudes, bin);
const float next = finiteMagnitude(magnitudes, bin + 1);
if (current >= previous && current >= next &&
(current > previous || current > next)) {
candidates.push_back(bin);
}
}
if (candidates.empty()) {
const auto best = std::max_element(
magnitudes.begin() + static_cast<std::ptrdiff_t>(firstBin),
magnitudes.begin() + static_cast<std::ptrdiff_t>(lastBin + 1));
candidates.push_back(static_cast<size_t>(
std::distance(magnitudes.begin(), best)));
}
const auto candidateScore = [&](size_t bin) {
const float frequency = frequencyForBin(
static_cast<float>(bin), sampleRate, fftSize);
return score(finiteMagnitude(magnitudes, bin), frequency, tiltDbPerOctave);
};
const auto better = [&](size_t left, size_t right) {
const float leftScore = candidateScore(left);
const float rightScore = candidateScore(right);
if (leftScore != rightScore) return leftScore > rightScore;
const float leftDb = finiteMagnitude(magnitudes, left);
const float rightDb = finiteMagnitude(magnitudes, right);
return leftDb != rightDb ? leftDb > rightDb : left < right;
};
size_t strongest = candidates.front();
for (size_t candidate : candidates) {
if (better(candidate, strongest)) strongest = candidate;
}
size_t selected = strongest;
if (previous_ && previous_->frequencyHz > 0.0f) {
std::optional<size_t> sticky;
for (size_t candidate : candidates) {
const float frequency = frequencyForBin(
static_cast<float>(candidate), sampleRate, fftSize);
const float distance = std::abs(std::log2(
frequency / previous_->frequencyHz));
if (distance <= kMaximumStickyDistanceOctaves &&
(!sticky || better(candidate, *sticky))) {
sticky = candidate;
}
}
if (sticky) {
const float upwardPenalty = strongest > *sticky
? kUpwardSwitchThresholdDb
: 0.0f;
if (candidateScore(strongest) <
candidateScore(*sticky) + kSwitchThresholdDb + upwardPenalty) {
selected = *sticky;
}
}
}
SpectrumPeakInfo result = peakAt(magnitudes, selected, sampleRate, fftSize);
if (!std::isfinite(result.dbfs) || result.dbfs <= kSilenceThresholdDbfs) {
previous_.reset();
return std::nullopt;
}
previous_ = result;
return result;
}
void SpectrumPeakTracker::reset() {
previous_.reset();
}
} // namespace Prism::Tui
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
namespace Prism::Tui {
struct SpectrumPeakInfo {
float dbfs = -100.0f;
float frequencyHz = 0.0f;
std::string pitch;
};
std::string formatSpectrumPitch(float frequencyHz);
class SpectrumPeakTracker {
public:
std::optional<SpectrumPeakInfo> select(const std::vector<float>& magnitudes,
float sampleRate,
size_t fftSize,
float tiltDbPerOctave);
void reset();
private:
std::optional<SpectrumPeakInfo> previous_;
};
} // namespace Prism::Tui
+641 -31
View File
@@ -3,21 +3,27 @@
#include "analysis_pipeline.h"
#include "dashboard_layout.h"
#include "display_model.h"
#include "scope_plot_model.h"
#include "snapshot_store.h"
#include "tui_settings.h"
#include <ftxui/component/component.hpp>
#include <ftxui/component/event.hpp>
#include <ftxui/component/screen_interactive.hpp>
#include <ftxui/dom/canvas.hpp>
#include <ftxui/dom/elements.hpp>
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <csignal>
#include <cstdio>
#include <exception>
#include <iomanip>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
@@ -34,7 +40,11 @@ namespace Prism::Tui {
namespace {
constexpr auto kCapturePollInterval = std::chrono::milliseconds(2);
constexpr auto kDisplayFrameInterval = std::chrono::milliseconds(33);
std::chrono::microseconds displayFrameInterval(int framesPerSecond) {
return std::chrono::microseconds(
1000000 / std::max(1, framesPerSecond));
}
volatile std::sig_atomic_t signalRequested = 0;
@@ -61,8 +71,11 @@ private:
struct DisplayFrame {
std::vector<float> magnitudes;
std::optional<SpectrumPeakInfo> spectrumPeak;
Visualizer::VUMeterSnapshot vu{};
Visualizer::LUFSMeterSnapshot lufs{};
OscilloscopeFrame oscilloscope;
VectorscopeFrame vectorscope;
double sampleRate = 48000.0;
std::string backend;
std::string device;
@@ -71,11 +84,30 @@ struct DisplayFrame {
struct InterfaceState {
PanelId focusedPanel = PanelId::Spectrum;
LayoutPreset layoutPreset = LayoutPreset::Automatic;
std::optional<PanelId> expandedPanel;
TuiSettings settings;
bool settingsOpen = false;
SettingsPage settingsPage = SettingsPage::Home;
size_t settingsHomeSelection = 0;
std::array<size_t, 5> settingsSelections{};
std::string settingsStatus;
};
std::string makeCaptureStatus(const DisplayFrame& frame, bool compact) {
size_t settingsPageIndex(SettingsPage page) {
return static_cast<size_t>(page);
}
size_t& settingsSelection(InterfaceState& state) {
return state.settingsSelections[settingsPageIndex(state.settingsPage)];
}
const size_t& settingsSelection(const InterfaceState& state) {
return state.settingsSelections[settingsPageIndex(state.settingsPage)];
}
std::string makeCaptureStatus(const DisplayFrame& frame,
const TuiSettings& settings,
bool compact) {
std::ostringstream sampleRate;
const double kilohertz = frame.sampleRate / 1000.0;
sampleRate << std::fixed << std::setprecision(
@@ -85,28 +117,68 @@ std::string makeCaptureStatus(const DisplayFrame& frame, bool compact) {
footer += frame.device + "";
}
footer += sampleRate.str() + " kHz";
if (settings.inputTrimDb != 0.0f) {
std::ostringstream trim;
trim << " • trim " << (settings.inputTrimDb > 0.0f ? "+" : "")
<< std::fixed << std::setprecision(1) << settings.inputTrimDb << " dB";
footer += trim.str();
}
if (frame.captureOverrun) {
footer += " • capture overrun";
}
return footer;
}
std::string formatSpectrumPeak(const SpectrumPeakInfo& peak, int width) {
std::ostringstream frequency;
frequency << std::fixed << std::setprecision(
peak.frequencyHz < 1000.0f ? 1 : 0) << peak.frequencyHz << " Hz";
if (width < 42) {
return frequency.str();
}
std::ostringstream db;
db << std::fixed << std::setprecision(1) << peak.dbfs << " dBFS";
if (width < 58) {
return db.str() + "" + frequency.str();
}
return db.str() + "" + frequency.str() + "" + peak.pitch;
}
std::string panelName(PanelId panel) {
switch (panel) {
case PanelId::Spectrum:
return "Spectrum";
case PanelId::Oscilloscope:
return "Oscilloscope";
case PanelId::Vectorscope:
return "Vectorscope";
case PanelId::Levels:
return "Levels";
}
return "Panel";
}
ftxui::Element panelTitle(PanelId panel, bool focused) {
std::string panelNumber(PanelId panel) {
switch (panel) {
case PanelId::Spectrum:
return "1";
case PanelId::Oscilloscope:
return "2";
case PanelId::Vectorscope:
return "3";
case PanelId::Levels:
return "4";
}
return "?";
}
ftxui::Element panelTitle(PanelId panel,
bool focused,
const std::string& detail = {}) {
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);
std::string label = " " + panelNumber(panel) + " " + panelName(panel);
if (!detail.empty()) {
label += "" + detail;
}
label += " ";
auto title = text(label);
@@ -123,7 +195,8 @@ ftxui::Element stylePanel(ftxui::Element content, bool focused) {
ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
int width,
int height,
bool focused) {
bool focused,
const TuiSettings& settings) {
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));
@@ -132,6 +205,7 @@ ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
SpectrumProjectionOptions projectionOptions;
projectionOptions.sampleRate = static_cast<float>(frame.sampleRate);
projectionOptions.maxFrequency = std::min(20000.0f, projectionOptions.sampleRate * 0.5f);
projectionOptions.tiltDbPerOctave = settings.spectrumTiltDbPerOctave;
const auto projected = projectSpectrum(
frame.magnitudes,
kDefaultFftSize,
@@ -150,14 +224,250 @@ ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
color(Color::GrayDark));
}
const std::string detail = settings.spectrumPeakReadout && frame.spectrumPeak
? formatSpectrumPeak(*frame.spectrumPeak, width)
: "FFT " + std::to_string(kDefaultFftSize);
auto panel = window(
panelTitle(PanelId::Spectrum, focused),
panelTitle(
PanelId::Spectrum,
focused,
detail),
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 renderOscilloscopePanel(const DisplayFrame& frame,
int width,
int height,
bool focused,
const TuiSettings& settings) {
using namespace ftxui;
std::string detail = settings.oscilloscopePitchLock ? "Pitch lock" : "Free run";
if (settings.oscilloscopeFrequencyReadout &&
frame.oscilloscope.signalPresent &&
std::isfinite(frame.oscilloscope.detectedPitch) &&
frame.oscilloscope.detectedPitch > 0.0f) {
detail = std::to_string(static_cast<int>(
std::lround(frame.oscilloscope.detectedPitch))) + " Hz" +
(settings.oscilloscopePitchLock ? " lock" : "");
}
auto plot = canvas([
samples = frame.oscilloscope.samples,
signalPresent = frame.oscilloscope.signalPresent,
traceWeight = settings.oscilloscopeTraceWeight
](Canvas& surface) {
const int canvasWidth = surface.width();
const int canvasHeight = surface.height();
if (canvasWidth <= 0 || canvasHeight <= 0) {
return;
}
const int centerY = oscilloscopeZeroY(canvasHeight);
surface.DrawPointLine(
0, centerY, canvasWidth - 1, centerY, Color::GrayDark);
if (!signalPresent) {
return;
}
const auto points = buildOscilloscopePlot(
samples, canvasWidth, canvasHeight);
for (size_t index = 1; index < points.size(); ++index) {
for (int thickness = 0; thickness < traceWeight; ++thickness) {
surface.DrawPointLine(
points[index - 1].x,
std::clamp(points[index - 1].y + thickness, 0, canvasHeight - 1),
points[index].x,
std::clamp(points[index].y + thickness, 0, canvasHeight - 1),
Color::CyanLight);
}
}
}) | flex;
auto panel = window(
panelTitle(PanelId::Oscilloscope, focused, detail),
std::move(plot));
return stylePanel(std::move(panel), focused) |
size(WIDTH, EQUAL, std::max(1, width)) |
size(HEIGHT, EQUAL, std::max(1, height));
}
void drawVectorscopeGrid(ftxui::Canvas& surface, VectorscopeMode mode) {
using ftxui::Color;
const auto layout = getVectorscopePlotLayout(
surface.width(), surface.height(), mode);
if (layout.radius <= 0 || mode == VectorscopeMode::Lissajous) {
return;
}
const auto grid = Color::RGB(76, 82, 88);
const auto guide = Color::RGB(48, 53, 58);
const int left = layout.centerX - layout.radius;
const int right = layout.centerX + layout.radius;
const int top = layout.centerY - layout.radius;
const int bottom = layout.centerY + layout.radius;
const int halfRadius = std::max(1, layout.radius / 2);
const int diagonal = static_cast<int>(std::lround(
static_cast<float>(layout.radius) * 0.70710678f));
const auto drawTriangle = [&](int radius, const Color& color) {
surface.DrawPointLine(
layout.centerX, layout.centerY - radius,
layout.centerX - radius, layout.centerY, color);
surface.DrawPointLine(
layout.centerX - radius, layout.centerY,
layout.centerX + radius, layout.centerY, color);
surface.DrawPointLine(
layout.centerX + radius, layout.centerY,
layout.centerX, layout.centerY - radius, color);
};
const auto drawDiamond = [&](int radius, const Color& color) {
surface.DrawPointLine(
layout.centerX, layout.centerY - radius,
layout.centerX + radius, layout.centerY, color);
surface.DrawPointLine(
layout.centerX + radius, layout.centerY,
layout.centerX, layout.centerY + radius, color);
surface.DrawPointLine(
layout.centerX, layout.centerY + radius,
layout.centerX - radius, layout.centerY, color);
surface.DrawPointLine(
layout.centerX - radius, layout.centerY,
layout.centerX, layout.centerY - radius, color);
};
switch (mode) {
case VectorscopeMode::PolarUnipolar:
surface.DrawPointCircle(
layout.centerX, layout.centerY, layout.radius, grid);
surface.DrawPointCircle(
layout.centerX, layout.centerY, halfRadius, guide);
surface.DrawPointLine(
layout.centerX, top, layout.centerX, layout.centerY, grid);
surface.DrawPointLine(
layout.centerX, layout.centerY,
layout.centerX - diagonal, layout.centerY - diagonal, guide);
surface.DrawPointLine(
layout.centerX, layout.centerY,
layout.centerX + diagonal, layout.centerY - diagonal, guide);
break;
case VectorscopeMode::PolarBipolar:
surface.DrawPointCircle(
layout.centerX, layout.centerY, layout.radius, grid);
surface.DrawPointCircle(
layout.centerX, layout.centerY, halfRadius, guide);
surface.DrawPointLine(
layout.centerX, top, layout.centerX, bottom, grid);
surface.DrawPointLine(
left, layout.centerY, right, layout.centerY, guide);
surface.DrawPointLine(
layout.centerX - diagonal, layout.centerY - diagonal,
layout.centerX + diagonal, layout.centerY + diagonal, guide);
surface.DrawPointLine(
layout.centerX + diagonal, layout.centerY - diagonal,
layout.centerX - diagonal, layout.centerY + diagonal, guide);
break;
case VectorscopeMode::LinearUnipolar:
drawTriangle(layout.radius, grid);
drawTriangle(halfRadius, guide);
surface.DrawPointLine(
layout.centerX, top, layout.centerX, layout.centerY, grid);
break;
case VectorscopeMode::LinearBipolar:
drawDiamond(layout.radius, grid);
drawDiamond(halfRadius, guide);
surface.DrawPointLine(
layout.centerX, top, layout.centerX, bottom, grid);
surface.DrawPointLine(
left, layout.centerY, right, layout.centerY, guide);
break;
case VectorscopeMode::Lissajous:
break;
}
}
ftxui::Element renderVectorscopePanel(const DisplayFrame& frame,
int width,
int height,
bool focused,
const TuiSettings& settings) {
using namespace ftxui;
const VectorscopeMode mode = settings.vectorscopeMode;
const int densityDivisor = settings.vectorscopeDetail == VectorscopeDetail::Balanced
? 10
: settings.vectorscopeDetail == VectorscopeDetail::Maximum ? 3 : 6;
auto plot = canvas([
multibandPoints = frame.vectorscope.multibandPoints,
pointCount = frame.vectorscope.pointCount,
mode,
showGuides = settings.vectorscopeGuides,
densityDivisor
](Canvas& surface) {
const int canvasWidth = surface.width();
const int canvasHeight = surface.height();
if (canvasWidth <= 0 || canvasHeight <= 0) {
return;
}
if (showGuides) {
drawVectorscopeGrid(surface, mode);
}
const auto bands = buildVectorscopePlot(
multibandPoints,
pointCount,
canvasWidth,
canvasHeight,
mode,
densityDivisor);
constexpr int ageBuckets = 8;
const std::array<std::array<int, 3>, 3> baseColors = {{
{{255, 68, 68}},
{{68, 221, 68}},
{{68, 136, 255}},
}};
std::array<std::array<Color, ageBuckets>, 3> colors;
for (size_t band = 0; band < colors.size(); ++band) {
for (int bucket = 0; bucket < ageBuckets; ++bucket) {
const float brightness = 0.3f + 0.7f *
static_cast<float>(bucket + 1) /
static_cast<float>(ageBuckets);
colors[band][bucket] = Color::RGB(
static_cast<uint8_t>(std::lround(
static_cast<float>(baseColors[band][0]) * brightness)),
static_cast<uint8_t>(std::lround(
static_cast<float>(baseColors[band][1]) * brightness)),
static_cast<uint8_t>(std::lround(
static_cast<float>(baseColors[band][2]) * brightness)));
}
}
for (int bucket = 0; bucket < ageBuckets; ++bucket) {
for (size_t band = 0; band < bands.size(); ++band) {
for (const auto& point : bands[band]) {
const int pointBucket = std::min(
ageBuckets - 1,
static_cast<int>(point.intensity *
static_cast<float>(ageBuckets)));
if (pointBucket != bucket) {
continue;
}
surface.DrawPoint(
point.x, point.y, true, colors[band][bucket]);
}
}
}
}) | flex;
auto panel = window(
panelTitle(
PanelId::Vectorscope,
focused,
vectorscopeModeName(mode)),
std::move(plot));
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,
@@ -219,17 +529,28 @@ const PanelRect* findPanelRect(const DashboardLayout& layout, PanelId panel) {
ftxui::Element renderLayoutNode(const LayoutNode& node,
const DashboardLayout& layout,
const DisplayFrame& frame,
PanelId focusedPanel) {
const InterfaceState& state) {
using namespace ftxui;
if (node.isLeaf()) {
const auto* rect = findPanelRect(layout, *node.panel);
if (rect == nullptr) {
return emptyElement();
}
const bool focused = *node.panel == focusedPanel;
const bool focused = *node.panel == state.focusedPanel;
switch (*node.panel) {
case PanelId::Spectrum:
return renderSpectrumPanel(frame, rect->width, rect->height, focused);
return renderSpectrumPanel(
frame, rect->width, rect->height, focused, state.settings);
case PanelId::Oscilloscope:
return renderOscilloscopePanel(
frame, rect->width, rect->height, focused, state.settings);
case PanelId::Vectorscope:
return renderVectorscopePanel(
frame,
rect->width,
rect->height,
focused,
state.settings);
case PanelId::Levels:
return renderLevelsPanel(frame, rect->width, rect->height, focused);
}
@@ -238,7 +559,7 @@ ftxui::Element renderLayoutNode(const LayoutNode& node,
Elements children;
children.reserve(node.children.size());
for (const auto& child : node.children) {
children.push_back(renderLayoutNode(child, layout, frame, focusedPanel));
children.push_back(renderLayoutNode(child, layout, frame, state));
}
return node.axis == SplitAxis::Columns
? hbox(std::move(children))
@@ -248,8 +569,8 @@ ftxui::Element renderLayoutNode(const LayoutNode& node,
ftxui::Element renderHeader(const DashboardLayout& layout,
const InterfaceState& state) {
using namespace ftxui;
std::string layoutName = layoutPresetName(state.layoutPreset);
if (state.layoutPreset == LayoutPreset::Automatic) {
std::string layoutName = layoutPresetName(state.settings.layoutPreset);
if (state.settings.layoutPreset == LayoutPreset::Automatic) {
layoutName += "" + layoutPresetName(layout.resolvedPreset);
}
return hbox({
@@ -270,11 +591,12 @@ ftxui::Element renderFooter(const DisplayFrame& frame,
const bool minimal = width < 64;
const std::string enterAction = state.expandedPanel ? "restore" : "expand";
const std::string controls = minimal
? "Tab • Enter • l • q"
? "Tab • Enter • s • 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;
? "Tab focus • Enter " + enterAction + "s settings • q quit"
: "Tab focus • Enter " + enterAction +
" • s settings • v mode • l layout • r reset • q quit";
auto status = text(makeCaptureStatus(frame, state.settings, compact)) | dim;
if (frame.captureOverrun) {
status = status | color(Color::RedLight);
}
@@ -285,13 +607,109 @@ ftxui::Element renderFooter(const DisplayFrame& frame,
});
}
ftxui::Element settingsRow(const std::string& label,
const std::string& value,
bool selected) {
using namespace ftxui;
auto row = hbox({
text(selected ? " " : " "),
text(label),
filler(),
text(value),
text(" "),
});
if (selected) {
row = row | color(Color::CyanLight) | bold |
bgcolor(Color::RGB(24, 42, 46));
} else {
row = row | color(Color::GrayLight);
}
return row | size(HEIGHT, EQUAL, 1);
}
ftxui::Element renderSettings(const InterfaceState& state,
int width,
int height) {
using namespace ftxui;
const int contentWidth = std::max(1, width - 2);
const int contentHeight = std::max(1, height - 2);
const std::string breadcrumb = state.settingsPage == SettingsPage::Home
? " PRISM / SETTINGS"
: " PRISM / SETTINGS " + std::string(settingsPageName(state.settingsPage));
Elements rows;
std::string selectedDescription;
const size_t maximumVisibleRows = static_cast<size_t>(
std::max(1, contentHeight - 8));
if (state.settingsPage == SettingsPage::Home) {
const auto pages = settingsPages();
const size_t selectedIndex = std::min(
state.settingsHomeSelection,
pages.empty() ? size_t{0} : pages.size() - 1);
const size_t firstVisible = selectedIndex >= maximumVisibleRows
? selectedIndex - maximumVisibleRows + 1
: 0;
const size_t lastVisible = std::min(
pages.size(), firstVisible + maximumVisibleRows);
for (size_t index = firstVisible; index < lastVisible; ++index) {
const bool selected = index == state.settingsHomeSelection;
rows.push_back(settingsRow(
std::to_string(index + 1) + " " + settingsPageName(pages[index]),
{},
selected));
if (selected) selectedDescription = settingsPageDescription(pages[index]);
}
} else {
const auto& settings = settingsForPage(state.settingsPage);
const size_t selectedIndex = std::min(
settingsSelection(state),
settings.empty() ? size_t{0} : settings.size() - 1);
const size_t firstVisible = selectedIndex >= maximumVisibleRows
? selectedIndex - maximumVisibleRows + 1
: 0;
const size_t lastVisible = std::min(
settings.size(), firstVisible + maximumVisibleRows);
for (size_t index = firstVisible; index < lastVisible; ++index) {
const bool selected = index == selectedIndex;
rows.push_back(settingsRow(
settings[index].name,
settingValue(state.settings, settings[index].id),
selected));
if (selected) selectedDescription = settings[index].description;
}
}
const std::string controls = state.settingsPage == SettingsPage::Home
? "↑↓ select • Enter open • s/Esc dashboard"
: contentWidth < 76
? "↑↓ select • ←→ adjust • Enter • Esc back"
: "↑↓ select • ←→ adjust • Enter toggle • Backspace default • Esc back";
auto content = vbox({
text(breadcrumb) | color(Color::CyanLight) | bold,
separator(),
text(settingsPageDescription(state.settingsPage)) | dim,
separatorEmpty(),
vbox(std::move(rows)),
filler(),
text(selectedDescription) | color(Color::GrayLight),
state.settingsStatus.empty()
? emptyElement()
: text(state.settingsStatus) | color(Color::RedLight),
separator(),
text(controls) | dim,
}) | size(WIDTH, EQUAL, contentWidth) |
size(HEIGHT, EQUAL, contentHeight);
return std::move(content) | borderRounded |
size(WIDTH, EQUAL, width) |
size(HEIGHT, EQUAL, height);
}
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);
width, height, state.settings.layoutPreset, state.expandedPanel);
if (layout.terminalTooSmall) {
return vbox({
filler(),
@@ -302,11 +720,22 @@ ftxui::Element renderFrame(const DisplayFrame& frame,
});
}
return vbox({
auto dashboard = vbox({
renderHeader(layout, state) | size(HEIGHT, EQUAL, 1),
renderLayoutNode(layout.root, layout, frame, state.focusedPanel),
renderLayoutNode(layout.root, layout, frame, state),
renderFooter(frame, state, width) | size(HEIGHT, EQUAL, 1),
});
if (!state.settingsOpen) {
return dashboard;
}
const int settingsWidth = std::min(84, std::max(40, width - 4));
const int settingsHeight = std::min(16, std::max(10, height - 2));
return dbox({
std::move(dashboard) | dim,
renderSettings(state, settingsWidth, settingsHeight) |
borderEmpty | clear_under | center,
});
}
} // namespace
@@ -327,7 +756,11 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
ScreenInteractive screen = ScreenInteractive::Fullscreen();
SnapshotStore<DisplayFrame> frameStore;
SnapshotStore<TuiSettings> settingsStore;
InterfaceState interfaceState;
const std::filesystem::path settingsPath = defaultSettingsPath();
interfaceState.settings = loadSettings(settingsPath);
settingsStore.publish(interfaceState.settings);
DisplayFrame initial;
initial.magnitudes.assign(kDefaultFftSize / 2, -100.0f);
initial.sampleRate = started.sampleRate;
@@ -337,12 +770,17 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
std::atomic<bool> running{true};
std::atomic<bool> resetRequested{false};
std::atomic<bool> redrawQueued{false};
std::exception_ptr workerError;
auto exitLoop = screen.ExitLoopClosure();
std::thread worker([&]() {
try {
AnalysisPipeline pipeline(static_cast<float>(started.sampleRate));
TuiSettings appliedSettings = settingsStore.read();
pipeline.setInputTrimDb(appliedSettings.inputTrimDb);
pipeline.setSpectrumTilt(appliedSettings.spectrumTiltDbPerOctave);
pipeline.setOscilloscopePitchLock(appliedSettings.oscilloscopePitchLock);
bool captureOverrun = false;
auto nextFrameAt = std::chrono::steady_clock::now();
@@ -357,6 +795,21 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
captureOverrun = false;
}
const TuiSettings requestedSettings = settingsStore.read();
if (requestedSettings != appliedSettings) {
const bool refreshChanged =
requestedSettings.refreshRate != appliedSettings.refreshRate;
pipeline.setInputTrimDb(requestedSettings.inputTrimDb);
pipeline.setSpectrumTilt(
requestedSettings.spectrumTiltDbPerOctave);
pipeline.setOscilloscopePitchLock(
requestedSettings.oscilloscopePitchLock);
appliedSettings = requestedSettings;
if (refreshChanged) {
nextFrameAt = std::chrono::steady_clock::now();
}
}
drainCapture(*capture, pipeline, captureOverrun);
const auto now = std::chrono::steady_clock::now();
@@ -364,15 +817,21 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
DisplayFrame next;
auto analyzed = pipeline.snapshot();
next.magnitudes = std::move(analyzed.magnitudes);
next.spectrumPeak = std::move(analyzed.spectrumPeak);
next.vu = analyzed.vu;
next.lufs = analyzed.lufs;
next.oscilloscope = std::move(analyzed.oscilloscope);
next.vectorscope = std::move(analyzed.vectorscope);
next.sampleRate = started.sampleRate;
next.backend = capture->backendName();
next.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel;
next.captureOverrun = captureOverrun;
frameStore.publish(std::move(next));
screen.PostEvent(Event::Custom);
nextFrameAt = now + kDisplayFrameInterval;
if (running.load() && !redrawQueued.exchange(true)) {
screen.PostEvent(Event::Custom);
}
nextFrameAt = now + displayFrameInterval(
appliedSettings.refreshRate);
}
std::this_thread::sleep_for(kCapturePollInterval);
}
@@ -388,8 +847,126 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
return renderFrame(
frameStore.read(), screen.dimx(), screen.dimy(), interfaceState);
});
const auto persistSettings = [&]() {
interfaceState.settings = normalizeSettings(interfaceState.settings);
settingsStore.publish(interfaceState.settings);
std::string error;
if (!saveSettings(interfaceState.settings, settingsPath, &error)) {
interfaceState.settingsStatus = "Settings were applied but could not be saved: " + error;
} else {
interfaceState.settingsStatus.clear();
}
};
const auto closeSettings = [&]() {
interfaceState.settingsOpen = false;
interfaceState.settingsPage = SettingsPage::Home;
const auto dashboard = buildDashboardLayout(
screen.dimx(),
screen.dimy(),
interfaceState.settings.layoutPreset,
interfaceState.expandedPanel);
if (!interfaceState.expandedPanel &&
!layoutContainsPanel(dashboard, interfaceState.focusedPanel)) {
const auto visible = visiblePanelOrder(dashboard);
if (!visible.empty()) interfaceState.focusedPanel = visible.front();
}
};
auto component = CatchEvent(renderer, [&](Event event) {
if (event == Event::Character('q') || event == Event::Escape || event == Event::CtrlC) {
if (event == Event::Custom) {
redrawQueued.store(false);
return false;
}
if (event == Event::Character('q') || event == Event::CtrlC) {
running.store(false);
exitLoop();
return true;
}
if (event == Event::Character('s')) {
if (interfaceState.settingsOpen) {
closeSettings();
} else {
interfaceState.settingsOpen = true;
interfaceState.settingsPage = SettingsPage::Home;
interfaceState.settingsHomeSelection = 0;
}
return true;
}
if (interfaceState.settingsOpen) {
if (event == Event::Escape) {
if (interfaceState.settingsPage == SettingsPage::Home) {
closeSettings();
} else {
const auto pages = settingsPages();
const auto found = std::find(
pages.begin(), pages.end(), interfaceState.settingsPage);
interfaceState.settingsHomeSelection = found == pages.end()
? 0
: static_cast<size_t>(std::distance(pages.begin(), found));
interfaceState.settingsPage = SettingsPage::Home;
}
return true;
}
if (interfaceState.settingsPage == SettingsPage::Home) {
const auto pages = settingsPages();
if (event == Event::ArrowUp || event == Event::ArrowDown) {
const int direction = event == Event::ArrowDown ? 1 : -1;
const int count = static_cast<int>(pages.size());
interfaceState.settingsHomeSelection = static_cast<size_t>(
(static_cast<int>(interfaceState.settingsHomeSelection) +
direction + count) % count);
return true;
}
if (event == Event::Return && !pages.empty()) {
interfaceState.settingsPage = pages[std::min(
interfaceState.settingsHomeSelection, pages.size() - 1)];
return true;
}
for (size_t index = 0; index < pages.size(); ++index) {
if (event == Event::Character(
static_cast<char>('1' + index))) {
interfaceState.settingsPage = pages[index];
interfaceState.settingsHomeSelection = index;
return true;
}
}
return true;
}
const auto& pageSettings = settingsForPage(interfaceState.settingsPage);
size_t& selected = settingsSelection(interfaceState);
if (!pageSettings.empty()) {
selected = std::min(selected, pageSettings.size() - 1);
}
if ((event == Event::ArrowUp || event == Event::ArrowDown) &&
!pageSettings.empty()) {
const int direction = event == Event::ArrowDown ? 1 : -1;
const int count = static_cast<int>(pageSettings.size());
selected = static_cast<size_t>(
(static_cast<int>(selected) + direction + count) % count);
return true;
}
if (!pageSettings.empty() &&
(event == Event::ArrowLeft || event == Event::ArrowRight ||
event == Event::Return)) {
const int direction = event == Event::ArrowLeft ? -1 : 1;
if (adjustSetting(
interfaceState.settings,
pageSettings[selected].id,
direction)) {
persistSettings();
}
return true;
}
if (!pageSettings.empty() && event == Event::Backspace) {
if (resetSetting(interfaceState.settings, pageSettings[selected].id)) {
persistSettings();
}
return true;
}
return true;
}
if (event == Event::Escape) {
running.store(false);
exitLoop();
return true;
@@ -398,9 +975,24 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
resetRequested.store(true);
return true;
}
if (event == Event::Character('v')) {
adjustSetting(
interfaceState.settings, SettingId::VectorscopeMode, 1);
persistSettings();
return true;
}
if (event == Event::Tab || event == Event::TabReverse) {
const auto navigationLayout = buildDashboardLayout(
screen.dimx(),
screen.dimy(),
interfaceState.settings.layoutPreset,
interfaceState.expandedPanel);
const auto navigationPanels = interfaceState.expandedPanel
? panelOrder()
: visiblePanelOrder(navigationLayout);
interfaceState.focusedPanel = nextPanel(
interfaceState.focusedPanel,
navigationPanels,
event == Event::TabReverse);
if (interfaceState.expandedPanel) {
interfaceState.expandedPanel = interfaceState.focusedPanel;
@@ -416,16 +1008,34 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
return true;
}
if (event == Event::Character('l')) {
interfaceState.layoutPreset = nextLayoutPreset(interfaceState.layoutPreset);
adjustSetting(interfaceState.settings, SettingId::Layout, 1);
persistSettings();
interfaceState.expandedPanel.reset();
const auto nextLayout = buildDashboardLayout(
screen.dimx(), screen.dimy(), interfaceState.settings.layoutPreset);
if (!layoutContainsPanel(nextLayout, interfaceState.focusedPanel)) {
const auto visible = visiblePanelOrder(nextLayout);
if (!visible.empty()) {
interfaceState.focusedPanel = visible.front();
}
}
return true;
}
if (event == Event::Character('1') || event == Event::Character('2')) {
interfaceState.focusedPanel = event == Event::Character('1')
? PanelId::Spectrum
: PanelId::Levels;
std::optional<PanelId> selectedPanel;
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 (selectedPanel) {
interfaceState.focusedPanel = *selectedPanel;
if (interfaceState.expandedPanel) {
interfaceState.expandedPanel = interfaceState.focusedPanel;
} else {
const auto currentLayout = buildDashboardLayout(
screen.dimx(), screen.dimy(), interfaceState.settings.layoutPreset);
if (!layoutContainsPanel(currentLayout, interfaceState.focusedPanel)) {
interfaceState.expandedPanel = interfaceState.focusedPanel;
}
}
return true;
}
+409
View File
@@ -0,0 +1,409 @@
#include "tui_settings.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <sstream>
#include <system_error>
namespace Prism::Tui {
namespace {
const std::vector<SettingDescriptor> kGeneralSettings = {
{SettingId::InputTrim, "Input trim", "Applies gain before every analyzer."},
{SettingId::RefreshRate, "Refresh rate", "Controls how often the terminal display is published."},
{SettingId::Layout, "Dashboard layout", "Chooses automatic, stacked, or column panes."},
};
const std::vector<SettingDescriptor> kSpectrumSettings = {
{SettingId::SpectrumPeakReadout, "Peak readout", "Shows the strongest stable spectral peak in the panel title."},
{SettingId::SpectrumTilt, "Display tilt", "Offsets the spectrum by decibels per octave around 1 kHz."},
};
const std::vector<SettingDescriptor> kOscilloscopeSettings = {
{SettingId::OscilloscopePitchLock, "Pitch lock", "Stabilizes the waveform around its detected fundamental."},
{SettingId::OscilloscopeFrequencyReadout, "Frequency readout", "Shows the live detected fundamental while pitch lock is enabled."},
{SettingId::OscilloscopeTraceWeight, "Trace weight", "Changes the thickness of the oscilloscope trace."},
};
const std::vector<SettingDescriptor> kVectorscopeSettings = {
{SettingId::VectorscopeMode, "Display mode", "Changes the stereo projection used by the vectorscope."},
{SettingId::VectorscopeGuides, "Guides", "Shows the mode-specific reference contours and axes."},
{SettingId::VectorscopeDetail, "Point detail", "Balances point density against terminal rendering cost."},
};
float snap(float value, float step) {
return std::round(value / step) * step;
}
std::string boolValue(bool enabled) {
return enabled ? "● On" : "○ Off";
}
std::string trimFloat(float value, int precision) {
std::ostringstream output;
output << std::fixed << std::setprecision(precision) << value;
return output.str();
}
std::string serializeLayout(LayoutPreset layout) {
return layoutPresetName(layout);
}
std::string serializeVectorMode(VectorscopeMode mode) {
switch (mode) {
case VectorscopeMode::Lissajous: return "lissajous";
case VectorscopeMode::PolarUnipolar: return "polar_unipolar";
case VectorscopeMode::PolarBipolar: return "polar_bipolar";
case VectorscopeMode::LinearUnipolar: return "linear_unipolar";
case VectorscopeMode::LinearBipolar: return "linear_bipolar";
}
return "lissajous";
}
std::string serializeVectorDetail(VectorscopeDetail detail) {
switch (detail) {
case VectorscopeDetail::Balanced: return "balanced";
case VectorscopeDetail::Detailed: return "detailed";
case VectorscopeDetail::Maximum: return "maximum";
}
return "detailed";
}
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;
return fallback;
}
float parseFloat(const std::string& value, float fallback) {
try {
size_t consumed = 0;
const float parsed = std::stof(value, &consumed);
return consumed == value.size() && std::isfinite(parsed) ? parsed : fallback;
} catch (...) {
return fallback;
}
}
int parseInt(const std::string& value, int fallback) {
try {
size_t consumed = 0;
const int parsed = std::stoi(value, &consumed);
return consumed == value.size() ? parsed : fallback;
} catch (...) {
return fallback;
}
}
LayoutPreset parseLayout(const std::string& value, LayoutPreset fallback) {
if (value == "auto") return LayoutPreset::Automatic;
if (value == "stacked") return LayoutPreset::Stacked;
if (value == "columns") return LayoutPreset::Columns;
return fallback;
}
VectorscopeMode parseVectorMode(const std::string& value, VectorscopeMode fallback) {
if (value == "lissajous") return VectorscopeMode::Lissajous;
if (value == "polar_unipolar") return VectorscopeMode::PolarUnipolar;
if (value == "polar_bipolar") return VectorscopeMode::PolarBipolar;
if (value == "linear_unipolar") return VectorscopeMode::LinearUnipolar;
if (value == "linear_bipolar") return VectorscopeMode::LinearBipolar;
return fallback;
}
VectorscopeDetail parseVectorDetail(const std::string& value,
VectorscopeDetail fallback) {
if (value == "balanced") return VectorscopeDetail::Balanced;
if (value == "detailed") return VectorscopeDetail::Detailed;
if (value == "maximum") return VectorscopeDetail::Maximum;
return fallback;
}
const char* environmentValue(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] != '\0' ? value : nullptr;
}
} // namespace
TuiSettings normalizeSettings(TuiSettings settings) {
settings.inputTrimDb = std::clamp(snap(settings.inputTrimDb, 0.5f), -12.0f, 12.0f);
settings.refreshRate = settings.refreshRate <= 30 ? 30 : 60;
settings.spectrumTiltDbPerOctave = std::clamp(
snap(settings.spectrumTiltDbPerOctave, 0.1f), -2.0f, 8.0f);
settings.oscilloscopeTraceWeight = std::clamp(settings.oscilloscopeTraceWeight, 1, 3);
if (!settings.oscilloscopePitchLock) {
settings.oscilloscopeFrequencyReadout = false;
}
return settings;
}
bool operator==(const TuiSettings& left, const TuiSettings& right) {
return left.inputTrimDb == right.inputTrimDb &&
left.refreshRate == right.refreshRate &&
left.layoutPreset == right.layoutPreset &&
left.spectrumPeakReadout == right.spectrumPeakReadout &&
left.spectrumTiltDbPerOctave == right.spectrumTiltDbPerOctave &&
left.oscilloscopePitchLock == right.oscilloscopePitchLock &&
left.oscilloscopeFrequencyReadout == right.oscilloscopeFrequencyReadout &&
left.oscilloscopeTraceWeight == right.oscilloscopeTraceWeight &&
left.vectorscopeMode == right.vectorscopeMode &&
left.vectorscopeGuides == right.vectorscopeGuides &&
left.vectorscopeDetail == right.vectorscopeDetail;
}
bool operator!=(const TuiSettings& left, const TuiSettings& right) {
return !(left == right);
}
std::vector<SettingsPage> settingsPages() {
return {
SettingsPage::General,
SettingsPage::Spectrum,
SettingsPage::Oscilloscope,
SettingsPage::Vectorscope,
};
}
const char* settingsPageName(SettingsPage page) {
switch (page) {
case SettingsPage::Home: return "Settings";
case SettingsPage::General: return "General";
case SettingsPage::Spectrum: return "Spectrum";
case SettingsPage::Oscilloscope: return "Oscilloscope";
case SettingsPage::Vectorscope: return "Vectorscope";
}
return "Settings";
}
const char* settingsPageDescription(SettingsPage page) {
switch (page) {
case SettingsPage::Home: return "Choose a section.";
case SettingsPage::General: return "Audio input and dashboard behavior.";
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.";
}
return {};
}
const std::vector<SettingDescriptor>& settingsForPage(SettingsPage page) {
switch (page) {
case SettingsPage::General: return kGeneralSettings;
case SettingsPage::Spectrum: return kSpectrumSettings;
case SettingsPage::Oscilloscope: return kOscilloscopeSettings;
case SettingsPage::Vectorscope: return kVectorscopeSettings;
case SettingsPage::Home: break;
}
static const std::vector<SettingDescriptor> empty;
return empty;
}
std::string settingValue(const TuiSettings& settings, SettingId setting) {
switch (setting) {
case SettingId::InputTrim:
return (settings.inputTrimDb > 0.0f ? "+" : "") +
trimFloat(settings.inputTrimDb, 1) + " dB";
case SettingId::RefreshRate:
return std::to_string(settings.refreshRate) + " FPS";
case SettingId::Layout:
return layoutPresetName(settings.layoutPreset);
case SettingId::SpectrumPeakReadout:
return boolValue(settings.spectrumPeakReadout);
case SettingId::SpectrumTilt:
return trimFloat(settings.spectrumTiltDbPerOctave, 1) + " dB/oct";
case SettingId::OscilloscopePitchLock:
return boolValue(settings.oscilloscopePitchLock);
case SettingId::OscilloscopeFrequencyReadout:
return boolValue(settings.oscilloscopeFrequencyReadout);
case SettingId::OscilloscopeTraceWeight:
return std::to_string(settings.oscilloscopeTraceWeight);
case SettingId::VectorscopeMode:
return vectorscopeModeName(settings.vectorscopeMode);
case SettingId::VectorscopeGuides:
return boolValue(settings.vectorscopeGuides);
case SettingId::VectorscopeDetail:
switch (settings.vectorscopeDetail) {
case VectorscopeDetail::Balanced: return "Balanced";
case VectorscopeDetail::Detailed: return "Detailed";
case VectorscopeDetail::Maximum: return "Maximum";
}
}
return {};
}
bool settingIsBoolean(SettingId setting) {
return setting == SettingId::SpectrumPeakReadout ||
setting == SettingId::OscilloscopePitchLock ||
setting == SettingId::OscilloscopeFrequencyReadout ||
setting == SettingId::VectorscopeGuides;
}
bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) {
if (direction == 0) return false;
const TuiSettings before = settings;
switch (setting) {
case SettingId::InputTrim:
settings.inputTrimDb += direction > 0 ? 0.5f : -0.5f;
break;
case SettingId::RefreshRate:
settings.refreshRate = settings.refreshRate == 60 ? 30 : 60;
break;
case SettingId::Layout:
if (direction > 0) {
settings.layoutPreset = nextLayoutPreset(settings.layoutPreset);
} else {
settings.layoutPreset = settings.layoutPreset == LayoutPreset::Automatic
? LayoutPreset::Columns
: settings.layoutPreset == LayoutPreset::Columns
? LayoutPreset::Stacked
: LayoutPreset::Automatic;
}
break;
case SettingId::SpectrumPeakReadout:
settings.spectrumPeakReadout = !settings.spectrumPeakReadout;
break;
case SettingId::SpectrumTilt:
settings.spectrumTiltDbPerOctave += direction > 0 ? 0.1f : -0.1f;
break;
case SettingId::OscilloscopePitchLock:
settings.oscilloscopePitchLock = !settings.oscilloscopePitchLock;
if (!settings.oscilloscopePitchLock) {
settings.oscilloscopeFrequencyReadout = false;
}
break;
case SettingId::OscilloscopeFrequencyReadout:
if (!settings.oscilloscopePitchLock) {
return false;
}
settings.oscilloscopeFrequencyReadout = !settings.oscilloscopeFrequencyReadout;
break;
case SettingId::OscilloscopeTraceWeight:
settings.oscilloscopeTraceWeight += direction > 0 ? 1 : -1;
break;
case SettingId::VectorscopeMode:
if (direction > 0) {
settings.vectorscopeMode = nextVectorscopeMode(settings.vectorscopeMode);
} else {
for (int index = 0; index < 4; ++index) {
settings.vectorscopeMode = nextVectorscopeMode(settings.vectorscopeMode);
}
}
break;
case SettingId::VectorscopeGuides:
settings.vectorscopeGuides = !settings.vectorscopeGuides;
break;
case SettingId::VectorscopeDetail: {
int value = static_cast<int>(settings.vectorscopeDetail);
value = std::clamp(value + (direction > 0 ? 1 : -1), 0, 2);
settings.vectorscopeDetail = static_cast<VectorscopeDetail>(value);
break;
}
}
settings = normalizeSettings(settings);
return settings != before;
}
bool resetSetting(TuiSettings& settings, SettingId setting) {
const TuiSettings defaults;
const TuiSettings before = settings;
switch (setting) {
case SettingId::InputTrim: settings.inputTrimDb = defaults.inputTrimDb; break;
case SettingId::RefreshRate: settings.refreshRate = defaults.refreshRate; break;
case SettingId::Layout: settings.layoutPreset = defaults.layoutPreset; break;
case SettingId::SpectrumPeakReadout: settings.spectrumPeakReadout = defaults.spectrumPeakReadout; break;
case SettingId::SpectrumTilt: settings.spectrumTiltDbPerOctave = defaults.spectrumTiltDbPerOctave; break;
case SettingId::OscilloscopePitchLock: settings.oscilloscopePitchLock = defaults.oscilloscopePitchLock; break;
case SettingId::OscilloscopeFrequencyReadout: settings.oscilloscopeFrequencyReadout = defaults.oscilloscopeFrequencyReadout; break;
case SettingId::OscilloscopeTraceWeight: settings.oscilloscopeTraceWeight = defaults.oscilloscopeTraceWeight; break;
case SettingId::VectorscopeMode: settings.vectorscopeMode = defaults.vectorscopeMode; break;
case SettingId::VectorscopeGuides: settings.vectorscopeGuides = defaults.vectorscopeGuides; break;
case SettingId::VectorscopeDetail: settings.vectorscopeDetail = defaults.vectorscopeDetail; break;
}
return settings != before;
}
std::filesystem::path defaultSettingsPath() {
#if defined(_WIN32)
if (const char* appData = environmentValue("APPDATA")) {
return std::filesystem::path(appData) / "Prism" / "tui.conf";
}
#elif defined(__APPLE__)
if (const char* home = environmentValue("HOME")) {
return std::filesystem::path(home) /
"Library" / "Application Support" / "Prism" / "tui.conf";
}
#else
if (const char* xdgConfig = environmentValue("XDG_CONFIG_HOME")) {
return std::filesystem::path(xdgConfig) / "prism" / "tui.conf";
}
if (const char* home = environmentValue("HOME")) {
return std::filesystem::path(home) / ".config" / "prism" / "tui.conf";
}
#endif
return std::filesystem::path("prism-tui.conf");
}
TuiSettings loadSettings(const std::filesystem::path& path) {
TuiSettings settings;
std::ifstream input(path);
std::string line;
while (std::getline(input, line)) {
const size_t separator = line.find('=');
if (separator == std::string::npos) continue;
const std::string key = line.substr(0, separator);
const std::string value = line.substr(separator + 1);
if (key == "input_trim_db") settings.inputTrimDb = parseFloat(value, settings.inputTrimDb);
else if (key == "refresh_rate") settings.refreshRate = parseInt(value, settings.refreshRate);
else if (key == "layout") settings.layoutPreset = parseLayout(value, settings.layoutPreset);
else if (key == "spectrum_peak") settings.spectrumPeakReadout = parseBool(value, settings.spectrumPeakReadout);
else if (key == "spectrum_tilt") settings.spectrumTiltDbPerOctave = parseFloat(value, settings.spectrumTiltDbPerOctave);
else if (key == "osc_pitch_lock") settings.oscilloscopePitchLock = parseBool(value, settings.oscilloscopePitchLock);
else if (key == "osc_frequency") settings.oscilloscopeFrequencyReadout = parseBool(value, settings.oscilloscopeFrequencyReadout);
else if (key == "osc_trace_weight") settings.oscilloscopeTraceWeight = parseInt(value, settings.oscilloscopeTraceWeight);
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);
}
return normalizeSettings(settings);
}
bool saveSettings(const TuiSettings& rawSettings,
const std::filesystem::path& path,
std::string* error) {
const TuiSettings settings = normalizeSettings(rawSettings);
std::error_code filesystemError;
if (!path.parent_path().empty()) {
std::filesystem::create_directories(path.parent_path(), filesystemError);
if (filesystemError) {
if (error) *error = filesystemError.message();
return false;
}
}
std::ofstream output(path, std::ios::trunc);
if (!output) {
if (error) *error = "could not open settings file";
return false;
}
output << "input_trim_db=" << settings.inputTrimDb << '\n'
<< "refresh_rate=" << settings.refreshRate << '\n'
<< "layout=" << serializeLayout(settings.layoutPreset) << '\n'
<< "spectrum_peak=" << (settings.spectrumPeakReadout ? "true" : "false") << '\n'
<< "spectrum_tilt=" << settings.spectrumTiltDbPerOctave << '\n'
<< "osc_pitch_lock=" << (settings.oscilloscopePitchLock ? "true" : "false") << '\n'
<< "osc_frequency=" << (settings.oscilloscopeFrequencyReadout ? "true" : "false") << '\n'
<< "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';
if (!output) {
if (error) *error = "could not write settings file";
return false;
}
return true;
}
} // namespace Prism::Tui
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include "dashboard_layout.h"
#include "scope_plot_model.h"
#include <filesystem>
#include <string>
#include <vector>
namespace Prism::Tui {
enum class SettingsPage {
Home,
General,
Spectrum,
Oscilloscope,
Vectorscope,
};
enum class SettingId {
InputTrim,
RefreshRate,
Layout,
SpectrumPeakReadout,
SpectrumTilt,
OscilloscopePitchLock,
OscilloscopeFrequencyReadout,
OscilloscopeTraceWeight,
VectorscopeMode,
VectorscopeGuides,
VectorscopeDetail,
};
enum class VectorscopeDetail {
Balanced,
Detailed,
Maximum,
};
struct TuiSettings {
float inputTrimDb = 0.0f;
int refreshRate = 60;
LayoutPreset layoutPreset = LayoutPreset::Automatic;
bool spectrumPeakReadout = true;
float spectrumTiltDbPerOctave = 2.0f;
bool oscilloscopePitchLock = true;
bool oscilloscopeFrequencyReadout = true;
int oscilloscopeTraceWeight = 2;
VectorscopeMode vectorscopeMode = VectorscopeMode::Lissajous;
bool vectorscopeGuides = true;
VectorscopeDetail vectorscopeDetail = VectorscopeDetail::Detailed;
};
struct SettingDescriptor {
SettingId id;
const char* name;
const char* description;
};
TuiSettings normalizeSettings(TuiSettings settings);
bool operator==(const TuiSettings& left, const TuiSettings& right);
bool operator!=(const TuiSettings& left, const TuiSettings& right);
std::vector<SettingsPage> settingsPages();
const char* settingsPageName(SettingsPage page);
const char* settingsPageDescription(SettingsPage page);
const std::vector<SettingDescriptor>& settingsForPage(SettingsPage page);
std::string settingValue(const TuiSettings& settings, SettingId setting);
bool settingIsBoolean(SettingId setting);
bool adjustSetting(TuiSettings& settings, SettingId setting, int direction);
bool resetSetting(TuiSettings& settings, SettingId setting);
std::filesystem::path defaultSettingsPath();
TuiSettings loadSettings(const std::filesystem::path& path);
bool saveSettings(const TuiSettings& settings,
const std::filesystem::path& path,
std::string* error = nullptr);
} // namespace Prism::Tui
+302 -6
View File
@@ -2,13 +2,17 @@
#include "cli.h"
#include "dashboard_layout.h"
#include "display_model.h"
#include "scope_plot_model.h"
#include "snapshot_store.h"
#include "spectrum_peak_model.h"
#include "system_audio_capture.h"
#include "tui_settings.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <deque>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
@@ -107,6 +111,8 @@ void testCli() {
"exclusive commands should not combine");
require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos,
"help should describe dashboard keyboard controls");
require(Prism::Tui::usageText().find("Cycle vectorscope") != std::string::npos,
"help should describe vectorscope mode controls");
}
void testProjectionAndLayout() {
@@ -149,13 +155,23 @@ void testProjectionAndLayout() {
require(!wide.terminalTooSmall &&
wide.resolvedPreset == Prism::Tui::LayoutPreset::Columns,
"wide, tall terminals should use the columns dashboard");
require(wide.panels.size() == 2 &&
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");
require(wide.panels.size() == 4 &&
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");
require(wide.panels[0].width == wide.panels[1].width &&
wide.panels[2].width == wide.panels[3].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,
"both dashboard columns should fill the available height");
const auto stacked = Prism::Tui::buildDashboardLayout(
80, 20, Prism::Tui::LayoutPreset::Automatic);
60, 20, Prism::Tui::LayoutPreset::Automatic);
require(stacked.resolvedPreset == Prism::Tui::LayoutPreset::Stacked,
"short terminals should stack their panels");
require(stacked.panels.size() == 2 &&
@@ -182,11 +198,250 @@ void testProjectionAndLayout() {
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,
Prism::Tui::PanelId::Oscilloscope,
"panel focus should cycle forward");
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) ==
Prism::Tui::PanelId::Levels,
"panel focus should cycle backward");
const auto compactPanels = Prism::Tui::visiblePanelOrder(stacked);
require(compactPanels.size() == 2 &&
Prism::Tui::nextPanel(
Prism::Tui::PanelId::Spectrum, compactPanels) == Prism::Tui::PanelId::Levels,
"compact layout focus should skip hidden visual scopes");
}
void testSpectrumPeakModel() {
constexpr float sampleRate = 48000.0f;
constexpr size_t fftSize = 4096;
const float targetBin = 440.0f * static_cast<float>(fftSize) / sampleRate;
std::vector<float> magnitudes(fftSize / 2, -100.0f);
for (size_t bin = 1; bin + 1 < magnitudes.size(); ++bin) {
const float distance = static_cast<float>(bin) - targetBin;
magnitudes[bin] = std::max(-100.0f, -12.0f - 4.0f * distance * distance);
}
Prism::Tui::SpectrumPeakTracker tracker;
const auto peak = tracker.select(magnitudes, sampleRate, fftSize, 2.0f);
require(peak.has_value(), "a deterministic spectral peak should be detected");
require(std::abs(peak->frequencyHz - 440.0f) < 1.0f,
"quadratic peak interpolation should recover sub-bin frequency");
require(std::abs(peak->dbfs + 12.0f) < 0.1f,
"peak readout should preserve the untilted dBFS value");
require(peak->pitch.find("A4") == 0,
"peak readout should include its musical pitch");
require(Prism::Tui::formatSpectrumPitch(261.6256f).find("C4") == 0,
"pitch formatting should use conventional note and octave names");
tracker.reset();
std::fill(magnitudes.begin(), magnitudes.end(), -100.0f);
require(!tracker.select(magnitudes, sampleRate, fftSize, 2.0f),
"silent spectra should not produce a peak readout");
}
void testSettingsModelAndPersistence() {
Prism::Tui::TuiSettings settings;
settings.inputTrimDb = 30.0f;
settings.refreshRate = 42;
settings.spectrumTiltDbPerOctave = -8.0f;
settings.oscilloscopeTraceWeight = 20;
settings = Prism::Tui::normalizeSettings(settings);
require(settings.inputTrimDb == 12.0f && settings.refreshRate == 60 &&
settings.spectrumTiltDbPerOctave == -2.0f &&
settings.oscilloscopeTraceWeight == 3,
"settings normalization should enforce public ranges");
const auto pages = Prism::Tui::settingsPages();
require(pages.size() == 4 &&
Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 3,
"settings should expose shallow category pages");
Prism::Tui::TuiSettings adjusted;
require(Prism::Tui::adjustSetting(
adjusted, Prism::Tui::SettingId::InputTrim, 1) &&
adjusted.inputTrimDb == 0.5f,
"numeric settings should adjust by their documented step");
require(Prism::Tui::adjustSetting(
adjusted, Prism::Tui::SettingId::SpectrumPeakReadout, 1) &&
!adjusted.spectrumPeakReadout,
"boolean settings should toggle directly");
require(Prism::Tui::adjustSetting(
adjusted, Prism::Tui::SettingId::OscilloscopePitchLock, 1) &&
!adjusted.oscilloscopePitchLock &&
!adjusted.oscilloscopeFrequencyReadout,
"disabling pitch lock should also disable its frequency readout");
require(!Prism::Tui::adjustSetting(
adjusted, Prism::Tui::SettingId::OscilloscopeFrequencyReadout, 1) &&
!adjusted.oscilloscopeFrequencyReadout,
"frequency readout should remain unavailable without pitch lock");
require(Prism::Tui::adjustSetting(
adjusted, Prism::Tui::SettingId::OscilloscopePitchLock, 1) &&
adjusted.oscilloscopePitchLock,
"pitch lock should remain independently re-enableable");
require(Prism::Tui::resetSetting(
adjusted, Prism::Tui::SettingId::InputTrim) &&
adjusted.inputTrimDb == 0.0f,
"individual settings should reset to defaults");
const auto settingsPath = std::filesystem::temp_directory_path() /
"prism-tui-settings-test.conf";
std::error_code ignored;
std::filesystem::remove(settingsPath, ignored);
adjusted.layoutPreset = Prism::Tui::LayoutPreset::Columns;
adjusted.vectorscopeMode = Prism::Tui::VectorscopeMode::PolarBipolar;
adjusted.vectorscopeDetail = Prism::Tui::VectorscopeDetail::Maximum;
std::string error;
require(Prism::Tui::saveSettings(adjusted, settingsPath, &error),
"settings should persist to a TUI-specific configuration file");
require(Prism::Tui::loadSettings(settingsPath) == adjusted,
"persisted settings should round-trip without changing values");
std::filesystem::remove(settingsPath, ignored);
}
void testScopePlotModels() {
const auto oscilloscope = Prism::Tui::buildOscilloscopePlot(
{-1.0f, 0.0f, 1.0f}, 9, 9);
require(oscilloscope.size() == 9,
"oscilloscope projection should fill every Braille pixel column");
require(oscilloscope.front().y == 8 && oscilloscope.back().y == 0,
"oscilloscope projection should preserve full-scale polarity");
require(std::all_of(
oscilloscope.begin(), oscilloscope.end(), [](const Prism::Tui::PlotPoint& point) {
return point.x >= 0 && point.x < 9 && point.y >= 0 && point.y < 9;
}), "oscilloscope projection should remain bounded");
const auto zeroLine = Prism::Tui::buildOscilloscopePlot({0.0f}, 1, 8);
require(zeroLine.front().y == Prism::Tui::oscilloscopeZeroY(8) &&
zeroLine.front().y == 4,
"the oscilloscope zero line should use the waveform's center rounding");
const std::vector<float> multiband = {
1.0f, 0.0f,
0.0f, 0.5f,
-1.0f, -0.5f,
};
const auto vectorscope = Prism::Tui::buildVectorscopePlot(
multiband, 1, 21, 21);
require(vectorscope[0].size() == 1 &&
vectorscope[1].size() == 1 &&
vectorscope[2].size() == 1,
"vectorscope projection should preserve all three frequency bands");
for (const auto& band : vectorscope) {
require(std::all_of(
band.begin(), band.end(), [](const Prism::Tui::PlotPoint& point) {
return point.x >= 0 && point.x < 21 && point.y >= 0 && point.y < 21;
}), "vectorscope projection should remain bounded");
}
require(Prism::Tui::buildOscilloscopePlot({}, 10, 10).empty(),
"an empty oscilloscope frame should render no points");
auto mode = Prism::Tui::VectorscopeMode::Lissajous;
for (int index = 0; index < 5; ++index) {
require(std::string(Prism::Tui::vectorscopeModeName(mode)).size() > 0,
"each vectorscope mode should have a display name");
mode = Prism::Tui::nextVectorscopeMode(mode);
}
require(mode == Prism::Tui::VectorscopeMode::Lissajous,
"vectorscope mode selection should cycle through all five modes");
const std::vector<float> correlated = {
0.25f, 0.25f,
0.25f, 0.25f,
0.25f, 0.25f,
};
const auto linear = Prism::Tui::buildVectorscopePlot(
correlated,
1,
41,
41,
Prism::Tui::VectorscopeMode::LinearBipolar);
const auto polar = Prism::Tui::buildVectorscopePlot(
correlated,
1,
41,
41,
Prism::Tui::VectorscopeMode::PolarBipolar);
const auto centeredLayout = Prism::Tui::getVectorscopePlotLayout(
41, 41, Prism::Tui::VectorscopeMode::LinearBipolar);
require(linear[0].front().x == centeredLayout.centerX &&
linear[0].front().y < centeredLayout.centerY,
"linear vectorscope mode should rotate correlated stereo onto the mono axis");
require(polar[0].front().y < linear[0].front().y,
"polar vectorscope mode should expand quiet points radially");
const std::vector<float> negativeMid = {
-0.5f, -0.5f,
-0.5f, -0.5f,
-0.5f, -0.5f,
};
const auto unipolar = Prism::Tui::buildVectorscopePlot(
negativeMid,
1,
41,
41,
Prism::Tui::VectorscopeMode::PolarUnipolar);
require(unipolar[0].empty() && unipolar[1].empty() && unipolar[2].empty(),
"unipolar vectorscope modes should omit negative-mid points");
const auto unipolarLayout = Prism::Tui::getVectorscopePlotLayout(
41, 41, Prism::Tui::VectorscopeMode::PolarUnipolar);
require(unipolarLayout.unipolar &&
unipolarLayout.centerY > centeredLayout.centerY,
"unipolar vectorscope modes should use the lower display origin");
std::vector<float> denseMultiband(300 * Visualizer::MULTIBAND_POINT_STRIDE, 0.2f);
const auto detailPreserving = Prism::Tui::buildVectorscopePlot(
denseMultiband,
300,
12,
12,
Prism::Tui::VectorscopeMode::Lissajous);
for (const auto& band : detailPreserving) {
require(band.size() <= 64,
"vectorscope projection should adapt its point budget to terminal resolution");
require(!band.empty() && band.front().intensity < band.back().intensity &&
band.back().intensity == 1.0f,
"vectorscope projection should retain chronological intensity information");
}
const auto balancedDetail = Prism::Tui::buildVectorscopePlot(
denseMultiband, 300, 30, 30, Prism::Tui::VectorscopeMode::Lissajous, 10);
const auto maximumDetail = Prism::Tui::buildVectorscopePlot(
denseMultiband, 300, 30, 30, Prism::Tui::VectorscopeMode::Lissajous, 3);
require(balancedDetail[0].size() < maximumDetail[0].size(),
"vectorscope detail settings should change the adaptive point budget");
}
void testPitchReadoutResponse() {
constexpr float sampleRate = 48000.0f;
Visualizer::Oscilloscope oscilloscope;
oscilloscope.setSampleRate(sampleRate);
oscilloscope.setPitchLock(true);
const auto lowTone = sineChunk(100.0f, 0.5f, 4096, sampleRate);
oscilloscope.pushSamples(lowTone.left.data(), lowTone.left.size());
for (int index = 0; index < 24; ++index) {
oscilloscope.process();
}
const auto highTone = sineChunk(400.0f, 0.5f, 4096, sampleRate);
oscilloscope.pushSamples(highTone.left.data(), highTone.left.size());
const auto locked = oscilloscope.process();
const float latest = oscilloscope.getLatestDetectedPitch();
require(latest > 0.0f &&
std::abs(latest - 400.0f) < std::abs(locked.detectedPitch - 400.0f),
"the fast pitch readout should respond before the stable trigger pitch");
Visualizer::Oscilloscope freeRunning;
freeRunning.setSampleRate(sampleRate);
freeRunning.setPitchLock(false);
freeRunning.setDisplaySamples(128);
const auto firstChunk = sineChunk(200.0f, 0.5f, 512, sampleRate);
freeRunning.pushSamples(firstChunk.left.data(), firstChunk.left.size());
const auto firstWindow = freeRunning.process();
require(firstWindow.triggerIndex == 384.0f,
"free-running oscilloscopes should show the newest complete window");
const auto nextChunk = sineChunk(200.0f, 0.5f, 64, sampleRate);
freeRunning.pushSamples(nextChunk.left.data(), nextChunk.left.size());
const auto nextWindow = freeRunning.process();
require(nextWindow.triggerIndex == 448.0f &&
nextWindow.triggerIndex != firstWindow.triggerIndex,
"free-running oscilloscope windows should advance with every audio chunk");
}
void testPipelineAndFakeCapture() {
@@ -216,6 +471,26 @@ void testPipelineAndFakeCapture() {
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.oscilloscope.samples.size() == 2048 &&
frame.oscilloscope.signalPresent,
"the pipeline should publish a live pitch-locked oscilloscope window");
require(std::isfinite(frame.oscilloscope.detectedPitch) &&
frame.oscilloscope.detectedPitch > 0.0f,
"the pipeline should publish the fast pitch readout");
require(std::all_of(
frame.oscilloscope.samples.begin(),
frame.oscilloscope.samples.end(),
[](float sample) { return std::isfinite(sample); }),
"oscilloscope samples should remain finite");
require(frame.vectorscope.pointCount == Prism::Tui::kVectorscopeDisplayPoints &&
frame.vectorscope.multibandPoints.size() ==
frame.vectorscope.pointCount * Visualizer::MULTIBAND_POINT_STRIDE,
"the pipeline should publish a full multiband vectorscope frame");
require(std::all_of(
frame.vectorscope.multibandPoints.begin(),
frame.vectorscope.multibandPoints.end(),
[](float sample) { return std::isfinite(sample); }),
"vectorscope samples should remain finite");
require(frame.vu.barLDb > -20.0f && frame.vu.barLDb < -5.0f,
"VU level should reflect deterministic input");
require(std::isfinite(frame.lufs.momentaryLUFS) && frame.lufs.momentaryLUFS > -60.0f,
@@ -225,6 +500,17 @@ void testPipelineAndFakeCapture() {
require(std::abs(frame.lufs.integratedLUFS + 12.03f) < 0.5f,
"integrated LUFS should match the deterministic stereo tone");
Prism::Tui::AnalysisPipeline trimmedPipeline(48000.0f);
trimmedPipeline.setInputTrimDb(6.0f);
for (int index = 0; index < 20; ++index) {
trimmedPipeline.process(sineChunk(1000.0f, 0.25f, 2400, 48000.0f));
}
const auto trimmed = trimmedPipeline.snapshot();
require(std::abs((trimmed.vu.barLDb - frame.vu.barLDb) - 6.0f) < 0.35f,
"input trim should affect the real VU analyzer before processing");
require(std::abs((trimmed.lufs.momentaryLUFS - frame.lufs.momentaryLUFS) - 6.0f) < 0.35f,
"input trim should affect the real loudness analyzer before processing");
Prism::Tui::AnalysisPipeline stereoPipeline(48000.0f);
for (int index = 0; index < 20; ++index) {
stereoPipeline.process(stereoSineChunk(1000.0f, 0.5f, 0.125f, 2400, 48000.0f));
@@ -235,6 +521,12 @@ void testPipelineAndFakeCapture() {
pipeline.reset();
const auto reset = pipeline.snapshot();
require(reset.lufs.integratedLUFS <= -59.0f, "reset should clear integrated loudness");
require(!reset.oscilloscope.signalPresent,
"reset should clear the oscilloscope display window");
require(reset.oscilloscope.detectedPitch == 0.0f,
"reset should clear the fast pitch readout");
require(reset.vectorscope.pointCount == 0 && reset.vectorscope.multibandPoints.empty(),
"reset should clear vectorscope history");
require(capture.stopped, "fake capture should stop cleanly");
}
@@ -260,6 +552,10 @@ void testThreadSafeSnapshots() {
int main() {
testCli();
testProjectionAndLayout();
testSpectrumPeakModel();
testSettingsModelAndPersistence();
testScopePlotModels();
testPitchReadoutResponse();
testPipelineAndFakeCapture();
testThreadSafeSnapshots();
std::cout << "Prism TUI tests passed\n";