mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-15 15:50:59 +02:00
settings, toggles, and more
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user