mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-19 12:04:18 +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
|
Installable Prism packages also provide `prism-tui`, a native terminal frontend
|
||||||
for the shared C++ capture and analysis engine. It shows a responsive spectrum,
|
for the shared C++ capture and analysis engine. It shows a responsive spectrum,
|
||||||
stereo VU meters, and momentary, short-term, and integrated LUFS readings. The
|
stereo VU meters, and momentary, short-term, and integrated LUFS readings.
|
||||||
dashboard uses a 4096-point FFT by default and automatically switches between
|
|
||||||
stacked and column layouts as the terminal is resized.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
prism-tui # Capture the default system output
|
prism-tui # Capture the default system output
|
||||||
@@ -63,13 +61,11 @@ prism-tui --help
|
|||||||
prism-tui --version
|
prism-tui --version
|
||||||
```
|
```
|
||||||
|
|
||||||
Press Tab or Shift-Tab to focus a panel, Enter to expand or restore it, and `l`
|
Press `r` to reset the analyzers and integrated loudness, or `q`, Escape, or
|
||||||
to cycle automatic, stacked, and column layouts. Number keys `1` and `2` focus
|
Ctrl-C to quit. Interactive mode requires a terminal of at least 44 by 12 cells.
|
||||||
Spectrum and Levels. Press `r` to reset the analyzers and integrated loudness,
|
Quote a device ID if it contains spaces. Successful help, version, listing, and
|
||||||
or `q`, Escape, or Ctrl-C to quit. Interactive mode requires a terminal of at
|
interactive exits return `0`; usage errors return `2`; capture and runtime
|
||||||
least 44 by 12 cells. Quote a device ID if it contains spaces. Successful help,
|
failures return `1`.
|
||||||
version, listing, and interactive exits return `0`; usage errors return `2`;
|
|
||||||
capture and runtime failures return `1`.
|
|
||||||
|
|
||||||
The v0 TUI captures system output only. Microphone/device-input capture, Prism
|
The v0 TUI captures system output only. Microphone/device-input capture, Prism
|
||||||
profiles and themes, file/stdin analysis, and the other visualizers remain GUI
|
profiles and themes, file/stdin analysis, and the other visualizers remain GUI
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Oscilloscope::Oscilloscope()
|
|||||||
, lastFilterPitch_(200.0f)
|
, lastFilterPitch_(200.0f)
|
||||||
, lastTrigger_(0)
|
, lastTrigger_(0)
|
||||||
, smoothedPitch_(200.0f)
|
, smoothedPitch_(200.0f)
|
||||||
|
, latestDetectedPitch_(0.0f)
|
||||||
, pitchSamplesProcessed_(0) {
|
, pitchSamplesProcessed_(0) {
|
||||||
|
|
||||||
// Initialize circular buffers
|
// Initialize circular buffers
|
||||||
@@ -144,6 +145,10 @@ OscilloscopeResult Oscilloscope::process() {
|
|||||||
result.detectedPitch = smoothedPitch_;
|
result.detectedPitch = smoothedPitch_;
|
||||||
|
|
||||||
if (!pitchLock_) {
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +177,7 @@ OscilloscopeResult Oscilloscope::process() {
|
|||||||
|
|
||||||
float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f);
|
float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f);
|
||||||
if (newPitch > 0.0f) {
|
if (newPitch > 0.0f) {
|
||||||
|
latestDetectedPitch_ = newPitch;
|
||||||
pitchSamplesProcessed_++;
|
pitchSamplesProcessed_++;
|
||||||
|
|
||||||
// Adaptive smoothing: fast convergence initially, then conservative
|
// Adaptive smoothing: fast convergence initially, then conservative
|
||||||
@@ -307,6 +313,7 @@ void Oscilloscope::reset() {
|
|||||||
writePos_ = 0;
|
writePos_ = 0;
|
||||||
lastTrigger_ = 0.0f;
|
lastTrigger_ = 0.0f;
|
||||||
smoothedPitch_ = 200.0f;
|
smoothedPitch_ = 200.0f;
|
||||||
|
latestDetectedPitch_ = 0.0f;
|
||||||
lastFilterPitch_ = 200.0f;
|
lastFilterPitch_ = 200.0f;
|
||||||
pitchSamplesProcessed_ = 0; // Reset warmup counter for fast convergence on next use
|
pitchSamplesProcessed_ = 0; // Reset warmup counter for fast convergence on next use
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ public:
|
|||||||
// Get current write position
|
// Get current write position
|
||||||
size_t getWritePos() const { return writePos_; }
|
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)
|
// Get samples from circular buffer (for rendering)
|
||||||
void getSamples(float* output, size_t startPos, size_t count) const;
|
void getSamples(float* output, size_t startPos, size_t count) const;
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ private:
|
|||||||
|
|
||||||
float lastTrigger_;
|
float lastTrigger_;
|
||||||
float smoothedPitch_;
|
float smoothedPitch_;
|
||||||
|
float latestDetectedPitch_;
|
||||||
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
|
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
|
||||||
|
|
||||||
// Internal helpers
|
// Internal helpers
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ add_library(prism_tui_analysis STATIC
|
|||||||
src/cli.cpp
|
src/cli.cpp
|
||||||
src/dashboard_layout.cpp
|
src/dashboard_layout.cpp
|
||||||
src/display_model.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}/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}/vumeter.cpp
|
||||||
${PRISM_NATIVE_DIR}/lufsmeter.cpp
|
${PRISM_NATIVE_DIR}/lufsmeter.cpp
|
||||||
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
|
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
|
||||||
|
|||||||
@@ -1,15 +1,39 @@
|
|||||||
#include "analysis_pipeline.h"
|
#include "analysis_pipeline.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace Prism::Tui {
|
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)
|
AnalysisPipeline::AnalysisPipeline(float sampleRate, size_t fftSize)
|
||||||
: spectrum_(fftSize) {
|
: spectrum_(fftSize), sampleRate_(sampleRate), fftSize_(fftSize) {
|
||||||
spectrum_.setSampleRate(sampleRate);
|
spectrum_.setSampleRate(sampleRate);
|
||||||
spectrum_.setSmoothing(0.9f);
|
spectrum_.setSmoothing(0.9f);
|
||||||
vu_.setSampleRate(sampleRate);
|
vu_.setSampleRate(sampleRate);
|
||||||
lufs_.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) {
|
void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) {
|
||||||
@@ -17,23 +41,96 @@ void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) {
|
|||||||
if (count == 0) {
|
if (count == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
spectrum_.pushStereoSamples(chunk.left.data(), chunk.right.data(), count);
|
const float* left = chunk.left.data();
|
||||||
vu_.pushSamples(chunk.left.data(), chunk.right.data(), count);
|
const float* right = chunk.right.data();
|
||||||
lufs_.pushSamples(chunk.left.data(), chunk.right.data(), count);
|
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() {
|
AnalysisFrame AnalysisPipeline::snapshot() {
|
||||||
return {
|
AnalysisFrame frame;
|
||||||
spectrum_.getChannelMaxMagnitudes(),
|
frame.magnitudes = spectrum_.getChannelMaxMagnitudes();
|
||||||
vu_.getSnapshot(),
|
frame.spectrumPeak = spectrumPeakTracker_.select(
|
||||||
lufs_.getSnapshot(),
|
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() {
|
void AnalysisPipeline::reset() {
|
||||||
spectrum_.reset();
|
spectrum_.reset();
|
||||||
vu_.reset();
|
vu_.reset();
|
||||||
lufs_.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,
|
size_t drainCapture(Prism::Capture::SystemAudioCapture& capture,
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "lufsmeter.h"
|
#include "lufsmeter.h"
|
||||||
|
#include "oscilloscope.h"
|
||||||
#include "spectrum.h"
|
#include "spectrum.h"
|
||||||
|
#include "spectrum_peak_model.h"
|
||||||
#include "system_audio_capture.h"
|
#include "system_audio_capture.h"
|
||||||
|
#include "vectorscope.h"
|
||||||
#include "vumeter.h"
|
#include "vumeter.h"
|
||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
namespace Prism::Tui {
|
namespace Prism::Tui {
|
||||||
|
|
||||||
constexpr size_t kDefaultFftSize = 4096;
|
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 {
|
struct AnalysisFrame {
|
||||||
std::vector<float> magnitudes;
|
std::vector<float> magnitudes;
|
||||||
|
std::optional<SpectrumPeakInfo> spectrumPeak;
|
||||||
Visualizer::VUMeterSnapshot vu{};
|
Visualizer::VUMeterSnapshot vu{};
|
||||||
Visualizer::LUFSMeterSnapshot lufs{};
|
Visualizer::LUFSMeterSnapshot lufs{};
|
||||||
|
OscilloscopeFrame oscilloscope;
|
||||||
|
VectorscopeFrame vectorscope;
|
||||||
};
|
};
|
||||||
|
|
||||||
class AnalysisPipeline {
|
class AnalysisPipeline {
|
||||||
@@ -24,11 +43,25 @@ public:
|
|||||||
void process(const Prism::Capture::AudioChunk& chunk);
|
void process(const Prism::Capture::AudioChunk& chunk);
|
||||||
AnalysisFrame snapshot();
|
AnalysisFrame snapshot();
|
||||||
void reset();
|
void reset();
|
||||||
|
void setInputTrimDb(float db);
|
||||||
|
void setSpectrumTilt(float dbPerOctave);
|
||||||
|
void setOscilloscopePitchLock(bool enabled);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Visualizer::Spectrum spectrum_;
|
Visualizer::Spectrum spectrum_;
|
||||||
Visualizer::VUMeterAnalyzer vu_;
|
Visualizer::VUMeterAnalyzer vu_;
|
||||||
Visualizer::LUFSMeterAnalyzer lufs_;
|
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,
|
size_t drainCapture(Prism::Capture::SystemAudioCapture& capture,
|
||||||
|
|||||||
+3
-1
@@ -60,8 +60,10 @@ std::string usageText() {
|
|||||||
"Controls:\n"
|
"Controls:\n"
|
||||||
" Tab / Shift-Tab Focus the next or previous panel.\n"
|
" Tab / Shift-Tab Focus the next or previous panel.\n"
|
||||||
" Enter Expand the focused panel or restore the dashboard.\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"
|
" 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"
|
" r Reset analyzers and integrated loudness.\n"
|
||||||
" q / Esc / Ctrl-C Quit.\n";
|
" q / Esc / Ctrl-C Quit.\n";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ MinimumSize panelMinimumSize(PanelId panel) {
|
|||||||
switch (panel) {
|
switch (panel) {
|
||||||
case PanelId::Spectrum:
|
case PanelId::Spectrum:
|
||||||
return {30, 5};
|
return {30, 5};
|
||||||
|
case PanelId::Oscilloscope:
|
||||||
|
return {30, 5};
|
||||||
|
case PanelId::Vectorscope:
|
||||||
|
return {30, 8};
|
||||||
case PanelId::Levels:
|
case PanelId::Levels:
|
||||||
return {30, 5};
|
return {30, 5};
|
||||||
}
|
}
|
||||||
@@ -134,13 +138,15 @@ void resolveNode(const LayoutNode& node,
|
|||||||
|
|
||||||
LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) {
|
LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) {
|
||||||
constexpr int minimumColumnsWidth = 72;
|
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;
|
return LayoutPreset::Stacked;
|
||||||
}
|
}
|
||||||
if (requested != LayoutPreset::Automatic) {
|
if (requested != LayoutPreset::Automatic) {
|
||||||
return requested;
|
return requested;
|
||||||
}
|
}
|
||||||
return width >= 96 && height >= 28
|
return width >= minimumColumnsWidth && height >= minimumColumnsHeight
|
||||||
? LayoutPreset::Columns
|
? LayoutPreset::Columns
|
||||||
: LayoutPreset::Stacked;
|
: LayoutPreset::Stacked;
|
||||||
}
|
}
|
||||||
@@ -148,8 +154,14 @@ LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) {
|
|||||||
LayoutNode makeRoot(LayoutPreset preset) {
|
LayoutNode makeRoot(LayoutPreset preset) {
|
||||||
if (preset == LayoutPreset::Columns) {
|
if (preset == LayoutPreset::Columns) {
|
||||||
return LayoutNode::split(SplitAxis::Columns, {
|
return LayoutNode::split(SplitAxis::Columns, {
|
||||||
LayoutNode::leaf(PanelId::Spectrum, 4),
|
LayoutNode::split(SplitAxis::Rows, {
|
||||||
LayoutNode::leaf(PanelId::Levels, 1),
|
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, {
|
return LayoutNode::split(SplitAxis::Rows, {
|
||||||
@@ -223,19 +235,50 @@ std::string layoutPresetName(LayoutPreset preset) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::vector<PanelId> panelOrder() {
|
std::vector<PanelId> panelOrder() {
|
||||||
return {PanelId::Spectrum, PanelId::Levels};
|
return {
|
||||||
|
PanelId::Spectrum,
|
||||||
|
PanelId::Oscilloscope,
|
||||||
|
PanelId::Vectorscope,
|
||||||
|
PanelId::Levels,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelId nextPanel(PanelId panel, bool reverse) {
|
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 auto found = std::find(panels.begin(), panels.end(), panel);
|
||||||
const size_t index = found == panels.end()
|
if (found == panels.end()) {
|
||||||
? 0
|
return reverse ? panels.back() : panels.front();
|
||||||
: static_cast<size_t>(std::distance(panels.begin(), found));
|
}
|
||||||
|
const size_t index = static_cast<size_t>(std::distance(panels.begin(), found));
|
||||||
if (reverse) {
|
if (reverse) {
|
||||||
return panels[(index + panels.size() - 1) % panels.size()];
|
return panels[(index + panels.size() - 1) % panels.size()];
|
||||||
}
|
}
|
||||||
return panels[(index + 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
|
} // namespace Prism::Tui
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ namespace Prism::Tui {
|
|||||||
|
|
||||||
enum class PanelId {
|
enum class PanelId {
|
||||||
Spectrum,
|
Spectrum,
|
||||||
|
Oscilloscope,
|
||||||
|
Vectorscope,
|
||||||
Levels,
|
Levels,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,5 +64,10 @@ LayoutPreset nextLayoutPreset(LayoutPreset preset);
|
|||||||
std::string layoutPresetName(LayoutPreset preset);
|
std::string layoutPresetName(LayoutPreset preset);
|
||||||
std::vector<PanelId> panelOrder();
|
std::vector<PanelId> panelOrder();
|
||||||
PanelId nextPanel(PanelId panel, bool reverse = false);
|
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
|
} // 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 "analysis_pipeline.h"
|
||||||
#include "dashboard_layout.h"
|
#include "dashboard_layout.h"
|
||||||
#include "display_model.h"
|
#include "display_model.h"
|
||||||
|
#include "scope_plot_model.h"
|
||||||
#include "snapshot_store.h"
|
#include "snapshot_store.h"
|
||||||
|
#include "tui_settings.h"
|
||||||
|
|
||||||
#include <ftxui/component/component.hpp>
|
#include <ftxui/component/component.hpp>
|
||||||
#include <ftxui/component/event.hpp>
|
#include <ftxui/component/event.hpp>
|
||||||
#include <ftxui/component/screen_interactive.hpp>
|
#include <ftxui/component/screen_interactive.hpp>
|
||||||
|
#include <ftxui/dom/canvas.hpp>
|
||||||
#include <ftxui/dom/elements.hpp>
|
#include <ftxui/dom/elements.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
|
#include <optional>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
@@ -34,7 +40,11 @@ namespace Prism::Tui {
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr auto kCapturePollInterval = std::chrono::milliseconds(2);
|
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;
|
volatile std::sig_atomic_t signalRequested = 0;
|
||||||
|
|
||||||
@@ -61,8 +71,11 @@ private:
|
|||||||
|
|
||||||
struct DisplayFrame {
|
struct DisplayFrame {
|
||||||
std::vector<float> magnitudes;
|
std::vector<float> magnitudes;
|
||||||
|
std::optional<SpectrumPeakInfo> spectrumPeak;
|
||||||
Visualizer::VUMeterSnapshot vu{};
|
Visualizer::VUMeterSnapshot vu{};
|
||||||
Visualizer::LUFSMeterSnapshot lufs{};
|
Visualizer::LUFSMeterSnapshot lufs{};
|
||||||
|
OscilloscopeFrame oscilloscope;
|
||||||
|
VectorscopeFrame vectorscope;
|
||||||
double sampleRate = 48000.0;
|
double sampleRate = 48000.0;
|
||||||
std::string backend;
|
std::string backend;
|
||||||
std::string device;
|
std::string device;
|
||||||
@@ -71,11 +84,30 @@ struct DisplayFrame {
|
|||||||
|
|
||||||
struct InterfaceState {
|
struct InterfaceState {
|
||||||
PanelId focusedPanel = PanelId::Spectrum;
|
PanelId focusedPanel = PanelId::Spectrum;
|
||||||
LayoutPreset layoutPreset = LayoutPreset::Automatic;
|
|
||||||
std::optional<PanelId> expandedPanel;
|
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;
|
std::ostringstream sampleRate;
|
||||||
const double kilohertz = frame.sampleRate / 1000.0;
|
const double kilohertz = frame.sampleRate / 1000.0;
|
||||||
sampleRate << std::fixed << std::setprecision(
|
sampleRate << std::fixed << std::setprecision(
|
||||||
@@ -85,28 +117,68 @@ std::string makeCaptureStatus(const DisplayFrame& frame, bool compact) {
|
|||||||
footer += frame.device + " • ";
|
footer += frame.device + " • ";
|
||||||
}
|
}
|
||||||
footer += sampleRate.str() + " kHz";
|
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) {
|
if (frame.captureOverrun) {
|
||||||
footer += " • capture overrun";
|
footer += " • capture overrun";
|
||||||
}
|
}
|
||||||
return footer;
|
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) {
|
std::string panelName(PanelId panel) {
|
||||||
switch (panel) {
|
switch (panel) {
|
||||||
case PanelId::Spectrum:
|
case PanelId::Spectrum:
|
||||||
return "Spectrum";
|
return "Spectrum";
|
||||||
|
case PanelId::Oscilloscope:
|
||||||
|
return "Oscilloscope";
|
||||||
|
case PanelId::Vectorscope:
|
||||||
|
return "Vectorscope";
|
||||||
case PanelId::Levels:
|
case PanelId::Levels:
|
||||||
return "Levels";
|
return "Levels";
|
||||||
}
|
}
|
||||||
return "Panel";
|
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;
|
using namespace ftxui;
|
||||||
const std::string number = panel == PanelId::Spectrum ? "1" : "2";
|
std::string label = " " + panelNumber(panel) + " " + panelName(panel);
|
||||||
std::string label = " " + number + " " + panelName(panel);
|
if (!detail.empty()) {
|
||||||
if (panel == PanelId::Spectrum) {
|
label += " • " + detail;
|
||||||
label += " • FFT " + std::to_string(kDefaultFftSize);
|
|
||||||
}
|
}
|
||||||
label += " ";
|
label += " ";
|
||||||
auto title = text(label);
|
auto title = text(label);
|
||||||
@@ -123,7 +195,8 @@ ftxui::Element stylePanel(ftxui::Element content, bool focused) {
|
|||||||
ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
|
ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
|
||||||
int width,
|
int width,
|
||||||
int height,
|
int height,
|
||||||
bool focused) {
|
bool focused,
|
||||||
|
const TuiSettings& settings) {
|
||||||
using namespace ftxui;
|
using namespace ftxui;
|
||||||
const size_t contentWidth = static_cast<size_t>(std::max(1, width - 2));
|
const size_t contentWidth = static_cast<size_t>(std::max(1, width - 2));
|
||||||
const size_t contentHeight = static_cast<size_t>(std::max(1, height - 2));
|
const size_t contentHeight = static_cast<size_t>(std::max(1, height - 2));
|
||||||
@@ -132,6 +205,7 @@ ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
|
|||||||
SpectrumProjectionOptions projectionOptions;
|
SpectrumProjectionOptions projectionOptions;
|
||||||
projectionOptions.sampleRate = static_cast<float>(frame.sampleRate);
|
projectionOptions.sampleRate = static_cast<float>(frame.sampleRate);
|
||||||
projectionOptions.maxFrequency = std::min(20000.0f, projectionOptions.sampleRate * 0.5f);
|
projectionOptions.maxFrequency = std::min(20000.0f, projectionOptions.sampleRate * 0.5f);
|
||||||
|
projectionOptions.tiltDbPerOctave = settings.spectrumTiltDbPerOctave;
|
||||||
const auto projected = projectSpectrum(
|
const auto projected = projectSpectrum(
|
||||||
frame.magnitudes,
|
frame.magnitudes,
|
||||||
kDefaultFftSize,
|
kDefaultFftSize,
|
||||||
@@ -150,14 +224,250 @@ ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
|
|||||||
color(Color::GrayDark));
|
color(Color::GrayDark));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const std::string detail = settings.spectrumPeakReadout && frame.spectrumPeak
|
||||||
|
? formatSpectrumPeak(*frame.spectrumPeak, width)
|
||||||
|
: "FFT " + std::to_string(kDefaultFftSize);
|
||||||
auto panel = window(
|
auto panel = window(
|
||||||
panelTitle(PanelId::Spectrum, focused),
|
panelTitle(
|
||||||
|
PanelId::Spectrum,
|
||||||
|
focused,
|
||||||
|
detail),
|
||||||
vbox(std::move(spectrumElements)));
|
vbox(std::move(spectrumElements)));
|
||||||
return stylePanel(std::move(panel), focused) |
|
return stylePanel(std::move(panel), focused) |
|
||||||
size(WIDTH, EQUAL, std::max(1, width)) |
|
size(WIDTH, EQUAL, std::max(1, width)) |
|
||||||
size(HEIGHT, EQUAL, std::max(1, height));
|
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,
|
ftxui::Element renderLevelsPanel(const DisplayFrame& frame,
|
||||||
int width,
|
int width,
|
||||||
int height,
|
int height,
|
||||||
@@ -219,17 +529,28 @@ const PanelRect* findPanelRect(const DashboardLayout& layout, PanelId panel) {
|
|||||||
ftxui::Element renderLayoutNode(const LayoutNode& node,
|
ftxui::Element renderLayoutNode(const LayoutNode& node,
|
||||||
const DashboardLayout& layout,
|
const DashboardLayout& layout,
|
||||||
const DisplayFrame& frame,
|
const DisplayFrame& frame,
|
||||||
PanelId focusedPanel) {
|
const InterfaceState& state) {
|
||||||
using namespace ftxui;
|
using namespace ftxui;
|
||||||
if (node.isLeaf()) {
|
if (node.isLeaf()) {
|
||||||
const auto* rect = findPanelRect(layout, *node.panel);
|
const auto* rect = findPanelRect(layout, *node.panel);
|
||||||
if (rect == nullptr) {
|
if (rect == nullptr) {
|
||||||
return emptyElement();
|
return emptyElement();
|
||||||
}
|
}
|
||||||
const bool focused = *node.panel == focusedPanel;
|
const bool focused = *node.panel == state.focusedPanel;
|
||||||
switch (*node.panel) {
|
switch (*node.panel) {
|
||||||
case PanelId::Spectrum:
|
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:
|
case PanelId::Levels:
|
||||||
return renderLevelsPanel(frame, rect->width, rect->height, focused);
|
return renderLevelsPanel(frame, rect->width, rect->height, focused);
|
||||||
}
|
}
|
||||||
@@ -238,7 +559,7 @@ ftxui::Element renderLayoutNode(const LayoutNode& node,
|
|||||||
Elements children;
|
Elements children;
|
||||||
children.reserve(node.children.size());
|
children.reserve(node.children.size());
|
||||||
for (const auto& child : node.children) {
|
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
|
return node.axis == SplitAxis::Columns
|
||||||
? hbox(std::move(children))
|
? hbox(std::move(children))
|
||||||
@@ -248,8 +569,8 @@ ftxui::Element renderLayoutNode(const LayoutNode& node,
|
|||||||
ftxui::Element renderHeader(const DashboardLayout& layout,
|
ftxui::Element renderHeader(const DashboardLayout& layout,
|
||||||
const InterfaceState& state) {
|
const InterfaceState& state) {
|
||||||
using namespace ftxui;
|
using namespace ftxui;
|
||||||
std::string layoutName = layoutPresetName(state.layoutPreset);
|
std::string layoutName = layoutPresetName(state.settings.layoutPreset);
|
||||||
if (state.layoutPreset == LayoutPreset::Automatic) {
|
if (state.settings.layoutPreset == LayoutPreset::Automatic) {
|
||||||
layoutName += "→" + layoutPresetName(layout.resolvedPreset);
|
layoutName += "→" + layoutPresetName(layout.resolvedPreset);
|
||||||
}
|
}
|
||||||
return hbox({
|
return hbox({
|
||||||
@@ -270,11 +591,12 @@ ftxui::Element renderFooter(const DisplayFrame& frame,
|
|||||||
const bool minimal = width < 64;
|
const bool minimal = width < 64;
|
||||||
const std::string enterAction = state.expandedPanel ? "restore" : "expand";
|
const std::string enterAction = state.expandedPanel ? "restore" : "expand";
|
||||||
const std::string controls = minimal
|
const std::string controls = minimal
|
||||||
? "Tab • Enter • l • q"
|
? "Tab • Enter • s • q"
|
||||||
: compact
|
: compact
|
||||||
? "Tab focus • Enter " + enterAction + " • l layout • q quit"
|
? "Tab focus • Enter " + enterAction + " • s settings • q quit"
|
||||||
: "Tab focus • Enter " + enterAction + " • l layout • r reset • q quit";
|
: "Tab focus • Enter " + enterAction +
|
||||||
auto status = text(makeCaptureStatus(frame, compact)) | dim;
|
" • s settings • v mode • l layout • r reset • q quit";
|
||||||
|
auto status = text(makeCaptureStatus(frame, state.settings, compact)) | dim;
|
||||||
if (frame.captureOverrun) {
|
if (frame.captureOverrun) {
|
||||||
status = status | color(Color::RedLight);
|
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,
|
ftxui::Element renderFrame(const DisplayFrame& frame,
|
||||||
int width,
|
int width,
|
||||||
int height,
|
int height,
|
||||||
const InterfaceState& state) {
|
const InterfaceState& state) {
|
||||||
using namespace ftxui;
|
using namespace ftxui;
|
||||||
const auto layout = buildDashboardLayout(
|
const auto layout = buildDashboardLayout(
|
||||||
width, height, state.layoutPreset, state.expandedPanel);
|
width, height, state.settings.layoutPreset, state.expandedPanel);
|
||||||
if (layout.terminalTooSmall) {
|
if (layout.terminalTooSmall) {
|
||||||
return vbox({
|
return vbox({
|
||||||
filler(),
|
filler(),
|
||||||
@@ -302,11 +720,22 @@ ftxui::Element renderFrame(const DisplayFrame& frame,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return vbox({
|
auto dashboard = vbox({
|
||||||
renderHeader(layout, state) | size(HEIGHT, EQUAL, 1),
|
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),
|
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
|
} // namespace
|
||||||
@@ -327,7 +756,11 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
|
|
||||||
ScreenInteractive screen = ScreenInteractive::Fullscreen();
|
ScreenInteractive screen = ScreenInteractive::Fullscreen();
|
||||||
SnapshotStore<DisplayFrame> frameStore;
|
SnapshotStore<DisplayFrame> frameStore;
|
||||||
|
SnapshotStore<TuiSettings> settingsStore;
|
||||||
InterfaceState interfaceState;
|
InterfaceState interfaceState;
|
||||||
|
const std::filesystem::path settingsPath = defaultSettingsPath();
|
||||||
|
interfaceState.settings = loadSettings(settingsPath);
|
||||||
|
settingsStore.publish(interfaceState.settings);
|
||||||
DisplayFrame initial;
|
DisplayFrame initial;
|
||||||
initial.magnitudes.assign(kDefaultFftSize / 2, -100.0f);
|
initial.magnitudes.assign(kDefaultFftSize / 2, -100.0f);
|
||||||
initial.sampleRate = started.sampleRate;
|
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> running{true};
|
||||||
std::atomic<bool> resetRequested{false};
|
std::atomic<bool> resetRequested{false};
|
||||||
|
std::atomic<bool> redrawQueued{false};
|
||||||
std::exception_ptr workerError;
|
std::exception_ptr workerError;
|
||||||
auto exitLoop = screen.ExitLoopClosure();
|
auto exitLoop = screen.ExitLoopClosure();
|
||||||
|
|
||||||
std::thread worker([&]() {
|
std::thread worker([&]() {
|
||||||
try {
|
try {
|
||||||
AnalysisPipeline pipeline(static_cast<float>(started.sampleRate));
|
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;
|
bool captureOverrun = false;
|
||||||
auto nextFrameAt = std::chrono::steady_clock::now();
|
auto nextFrameAt = std::chrono::steady_clock::now();
|
||||||
@@ -357,6 +795,21 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
captureOverrun = false;
|
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);
|
drainCapture(*capture, pipeline, captureOverrun);
|
||||||
|
|
||||||
const auto now = std::chrono::steady_clock::now();
|
const auto now = std::chrono::steady_clock::now();
|
||||||
@@ -364,15 +817,21 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
DisplayFrame next;
|
DisplayFrame next;
|
||||||
auto analyzed = pipeline.snapshot();
|
auto analyzed = pipeline.snapshot();
|
||||||
next.magnitudes = std::move(analyzed.magnitudes);
|
next.magnitudes = std::move(analyzed.magnitudes);
|
||||||
|
next.spectrumPeak = std::move(analyzed.spectrumPeak);
|
||||||
next.vu = analyzed.vu;
|
next.vu = analyzed.vu;
|
||||||
next.lufs = analyzed.lufs;
|
next.lufs = analyzed.lufs;
|
||||||
|
next.oscilloscope = std::move(analyzed.oscilloscope);
|
||||||
|
next.vectorscope = std::move(analyzed.vectorscope);
|
||||||
next.sampleRate = started.sampleRate;
|
next.sampleRate = started.sampleRate;
|
||||||
next.backend = capture->backendName();
|
next.backend = capture->backendName();
|
||||||
next.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel;
|
next.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel;
|
||||||
next.captureOverrun = captureOverrun;
|
next.captureOverrun = captureOverrun;
|
||||||
frameStore.publish(std::move(next));
|
frameStore.publish(std::move(next));
|
||||||
screen.PostEvent(Event::Custom);
|
if (running.load() && !redrawQueued.exchange(true)) {
|
||||||
nextFrameAt = now + kDisplayFrameInterval;
|
screen.PostEvent(Event::Custom);
|
||||||
|
}
|
||||||
|
nextFrameAt = now + displayFrameInterval(
|
||||||
|
appliedSettings.refreshRate);
|
||||||
}
|
}
|
||||||
std::this_thread::sleep_for(kCapturePollInterval);
|
std::this_thread::sleep_for(kCapturePollInterval);
|
||||||
}
|
}
|
||||||
@@ -388,8 +847,126 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
return renderFrame(
|
return renderFrame(
|
||||||
frameStore.read(), screen.dimx(), screen.dimy(), interfaceState);
|
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) {
|
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);
|
running.store(false);
|
||||||
exitLoop();
|
exitLoop();
|
||||||
return true;
|
return true;
|
||||||
@@ -398,9 +975,24 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
resetRequested.store(true);
|
resetRequested.store(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (event == Event::Character('v')) {
|
||||||
|
adjustSetting(
|
||||||
|
interfaceState.settings, SettingId::VectorscopeMode, 1);
|
||||||
|
persistSettings();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (event == Event::Tab || event == Event::TabReverse) {
|
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 = nextPanel(
|
||||||
interfaceState.focusedPanel,
|
interfaceState.focusedPanel,
|
||||||
|
navigationPanels,
|
||||||
event == Event::TabReverse);
|
event == Event::TabReverse);
|
||||||
if (interfaceState.expandedPanel) {
|
if (interfaceState.expandedPanel) {
|
||||||
interfaceState.expandedPanel = interfaceState.focusedPanel;
|
interfaceState.expandedPanel = interfaceState.focusedPanel;
|
||||||
@@ -416,16 +1008,34 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (event == Event::Character('l')) {
|
if (event == Event::Character('l')) {
|
||||||
interfaceState.layoutPreset = nextLayoutPreset(interfaceState.layoutPreset);
|
adjustSetting(interfaceState.settings, SettingId::Layout, 1);
|
||||||
|
persistSettings();
|
||||||
interfaceState.expandedPanel.reset();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
if (event == Event::Character('1') || event == Event::Character('2')) {
|
std::optional<PanelId> selectedPanel;
|
||||||
interfaceState.focusedPanel = event == Event::Character('1')
|
if (event == Event::Character('1')) selectedPanel = PanelId::Spectrum;
|
||||||
? PanelId::Spectrum
|
if (event == Event::Character('2')) selectedPanel = PanelId::Oscilloscope;
|
||||||
: PanelId::Levels;
|
if (event == Event::Character('3')) selectedPanel = PanelId::Vectorscope;
|
||||||
|
if (event == Event::Character('4')) selectedPanel = PanelId::Levels;
|
||||||
|
if (selectedPanel) {
|
||||||
|
interfaceState.focusedPanel = *selectedPanel;
|
||||||
if (interfaceState.expandedPanel) {
|
if (interfaceState.expandedPanel) {
|
||||||
interfaceState.expandedPanel = interfaceState.focusedPanel;
|
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;
|
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 "cli.h"
|
||||||
#include "dashboard_layout.h"
|
#include "dashboard_layout.h"
|
||||||
#include "display_model.h"
|
#include "display_model.h"
|
||||||
|
#include "scope_plot_model.h"
|
||||||
#include "snapshot_store.h"
|
#include "snapshot_store.h"
|
||||||
|
#include "spectrum_peak_model.h"
|
||||||
#include "system_audio_capture.h"
|
#include "system_audio_capture.h"
|
||||||
|
#include "tui_settings.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
|
#include <filesystem>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -107,6 +111,8 @@ void testCli() {
|
|||||||
"exclusive commands should not combine");
|
"exclusive commands should not combine");
|
||||||
require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos,
|
require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos,
|
||||||
"help should describe dashboard keyboard controls");
|
"help should describe dashboard keyboard controls");
|
||||||
|
require(Prism::Tui::usageText().find("Cycle vectorscope") != std::string::npos,
|
||||||
|
"help should describe vectorscope mode controls");
|
||||||
}
|
}
|
||||||
|
|
||||||
void testProjectionAndLayout() {
|
void testProjectionAndLayout() {
|
||||||
@@ -149,13 +155,23 @@ void testProjectionAndLayout() {
|
|||||||
require(!wide.terminalTooSmall &&
|
require(!wide.terminalTooSmall &&
|
||||||
wide.resolvedPreset == Prism::Tui::LayoutPreset::Columns,
|
wide.resolvedPreset == Prism::Tui::LayoutPreset::Columns,
|
||||||
"wide, tall terminals should use the columns dashboard");
|
"wide, tall terminals should use the columns dashboard");
|
||||||
require(wide.panels.size() == 2 &&
|
require(wide.panels.size() == 4 &&
|
||||||
wide.panels[0].width + wide.panels[1].width == 100 &&
|
wide.panels[0].panel == Prism::Tui::PanelId::Spectrum &&
|
||||||
wide.panels[0].width > wide.panels[1].width,
|
wide.panels[1].panel == Prism::Tui::PanelId::Oscilloscope &&
|
||||||
"column panels should fill the width and favor the spectrum");
|
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(
|
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,
|
require(stacked.resolvedPreset == Prism::Tui::LayoutPreset::Stacked,
|
||||||
"short terminals should stack their panels");
|
"short terminals should stack their panels");
|
||||||
require(stacked.panels.size() == 2 &&
|
require(stacked.panels.size() == 2 &&
|
||||||
@@ -182,11 +198,250 @@ void testProjectionAndLayout() {
|
|||||||
expanded.panels[0].width == 100 && expanded.panels[0].height == 28,
|
expanded.panels[0].width == 100 && expanded.panels[0].height == 28,
|
||||||
"expanded panels should occupy the complete dashboard area");
|
"expanded panels should occupy the complete dashboard area");
|
||||||
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum) ==
|
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum) ==
|
||||||
Prism::Tui::PanelId::Levels,
|
Prism::Tui::PanelId::Oscilloscope,
|
||||||
"panel focus should cycle forward");
|
"panel focus should cycle forward");
|
||||||
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) ==
|
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) ==
|
||||||
Prism::Tui::PanelId::Levels,
|
Prism::Tui::PanelId::Levels,
|
||||||
"panel focus should cycle backward");
|
"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() {
|
void testPipelineAndFakeCapture() {
|
||||||
@@ -216,6 +471,26 @@ void testPipelineAndFakeCapture() {
|
|||||||
const auto frame = pipeline.snapshot();
|
const auto frame = pipeline.snapshot();
|
||||||
require(frame.magnitudes.size() == Prism::Tui::kDefaultFftSize / 2,
|
require(frame.magnitudes.size() == Prism::Tui::kDefaultFftSize / 2,
|
||||||
"the default analysis pipeline should publish the 4096-point spectrum");
|
"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,
|
require(frame.vu.barLDb > -20.0f && frame.vu.barLDb < -5.0f,
|
||||||
"VU level should reflect deterministic input");
|
"VU level should reflect deterministic input");
|
||||||
require(std::isfinite(frame.lufs.momentaryLUFS) && frame.lufs.momentaryLUFS > -60.0f,
|
require(std::isfinite(frame.lufs.momentaryLUFS) && frame.lufs.momentaryLUFS > -60.0f,
|
||||||
@@ -225,6 +500,17 @@ void testPipelineAndFakeCapture() {
|
|||||||
require(std::abs(frame.lufs.integratedLUFS + 12.03f) < 0.5f,
|
require(std::abs(frame.lufs.integratedLUFS + 12.03f) < 0.5f,
|
||||||
"integrated LUFS should match the deterministic stereo tone");
|
"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);
|
Prism::Tui::AnalysisPipeline stereoPipeline(48000.0f);
|
||||||
for (int index = 0; index < 20; ++index) {
|
for (int index = 0; index < 20; ++index) {
|
||||||
stereoPipeline.process(stereoSineChunk(1000.0f, 0.5f, 0.125f, 2400, 48000.0f));
|
stereoPipeline.process(stereoSineChunk(1000.0f, 0.5f, 0.125f, 2400, 48000.0f));
|
||||||
@@ -235,6 +521,12 @@ void testPipelineAndFakeCapture() {
|
|||||||
pipeline.reset();
|
pipeline.reset();
|
||||||
const auto reset = pipeline.snapshot();
|
const auto reset = pipeline.snapshot();
|
||||||
require(reset.lufs.integratedLUFS <= -59.0f, "reset should clear integrated loudness");
|
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");
|
require(capture.stopped, "fake capture should stop cleanly");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +552,10 @@ void testThreadSafeSnapshots() {
|
|||||||
int main() {
|
int main() {
|
||||||
testCli();
|
testCli();
|
||||||
testProjectionAndLayout();
|
testProjectionAndLayout();
|
||||||
|
testSpectrumPeakModel();
|
||||||
|
testSettingsModelAndPersistence();
|
||||||
|
testScopePlotModels();
|
||||||
|
testPitchReadoutResponse();
|
||||||
testPipelineAndFakeCapture();
|
testPipelineAndFakeCapture();
|
||||||
testThreadSafeSnapshots();
|
testThreadSafeSnapshots();
|
||||||
std::cout << "Prism TUI tests passed\n";
|
std::cout << "Prism TUI tests passed\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user