mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-16 08:10:40 +02:00
settings, toggles, and more
This commit is contained in:
+302
-6
@@ -2,13 +2,17 @@
|
||||
#include "cli.h"
|
||||
#include "dashboard_layout.h"
|
||||
#include "display_model.h"
|
||||
#include "scope_plot_model.h"
|
||||
#include "snapshot_store.h"
|
||||
#include "spectrum_peak_model.h"
|
||||
#include "system_audio_capture.h"
|
||||
#include "tui_settings.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -107,6 +111,8 @@ void testCli() {
|
||||
"exclusive commands should not combine");
|
||||
require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos,
|
||||
"help should describe dashboard keyboard controls");
|
||||
require(Prism::Tui::usageText().find("Cycle vectorscope") != std::string::npos,
|
||||
"help should describe vectorscope mode controls");
|
||||
}
|
||||
|
||||
void testProjectionAndLayout() {
|
||||
@@ -149,13 +155,23 @@ void testProjectionAndLayout() {
|
||||
require(!wide.terminalTooSmall &&
|
||||
wide.resolvedPreset == Prism::Tui::LayoutPreset::Columns,
|
||||
"wide, tall terminals should use the columns dashboard");
|
||||
require(wide.panels.size() == 2 &&
|
||||
wide.panels[0].width + wide.panels[1].width == 100 &&
|
||||
wide.panels[0].width > wide.panels[1].width,
|
||||
"column panels should fill the width and favor the spectrum");
|
||||
require(wide.panels.size() == 4 &&
|
||||
wide.panels[0].panel == Prism::Tui::PanelId::Spectrum &&
|
||||
wide.panels[1].panel == Prism::Tui::PanelId::Oscilloscope &&
|
||||
wide.panels[2].panel == Prism::Tui::PanelId::Vectorscope &&
|
||||
wide.panels[3].panel == Prism::Tui::PanelId::Levels,
|
||||
"the dashboard should contain all four scope panels");
|
||||
require(wide.panels[0].width == wide.panels[1].width &&
|
||||
wide.panels[2].width == wide.panels[3].width &&
|
||||
wide.panels[0].width + wide.panels[2].width == 100 &&
|
||||
wide.panels[0].width > wide.panels[2].width,
|
||||
"dashboard columns should fill the width and favor visual plots");
|
||||
require(wide.panels[0].height + wide.panels[1].height == 28 &&
|
||||
wide.panels[2].height + wide.panels[3].height == 28,
|
||||
"both dashboard columns should fill the available height");
|
||||
|
||||
const auto stacked = Prism::Tui::buildDashboardLayout(
|
||||
80, 20, Prism::Tui::LayoutPreset::Automatic);
|
||||
60, 20, Prism::Tui::LayoutPreset::Automatic);
|
||||
require(stacked.resolvedPreset == Prism::Tui::LayoutPreset::Stacked,
|
||||
"short terminals should stack their panels");
|
||||
require(stacked.panels.size() == 2 &&
|
||||
@@ -182,11 +198,250 @@ void testProjectionAndLayout() {
|
||||
expanded.panels[0].width == 100 && expanded.panels[0].height == 28,
|
||||
"expanded panels should occupy the complete dashboard area");
|
||||
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum) ==
|
||||
Prism::Tui::PanelId::Levels,
|
||||
Prism::Tui::PanelId::Oscilloscope,
|
||||
"panel focus should cycle forward");
|
||||
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) ==
|
||||
Prism::Tui::PanelId::Levels,
|
||||
"panel focus should cycle backward");
|
||||
const auto compactPanels = Prism::Tui::visiblePanelOrder(stacked);
|
||||
require(compactPanels.size() == 2 &&
|
||||
Prism::Tui::nextPanel(
|
||||
Prism::Tui::PanelId::Spectrum, compactPanels) == Prism::Tui::PanelId::Levels,
|
||||
"compact layout focus should skip hidden visual scopes");
|
||||
}
|
||||
|
||||
void testSpectrumPeakModel() {
|
||||
constexpr float sampleRate = 48000.0f;
|
||||
constexpr size_t fftSize = 4096;
|
||||
const float targetBin = 440.0f * static_cast<float>(fftSize) / sampleRate;
|
||||
std::vector<float> magnitudes(fftSize / 2, -100.0f);
|
||||
for (size_t bin = 1; bin + 1 < magnitudes.size(); ++bin) {
|
||||
const float distance = static_cast<float>(bin) - targetBin;
|
||||
magnitudes[bin] = std::max(-100.0f, -12.0f - 4.0f * distance * distance);
|
||||
}
|
||||
|
||||
Prism::Tui::SpectrumPeakTracker tracker;
|
||||
const auto peak = tracker.select(magnitudes, sampleRate, fftSize, 2.0f);
|
||||
require(peak.has_value(), "a deterministic spectral peak should be detected");
|
||||
require(std::abs(peak->frequencyHz - 440.0f) < 1.0f,
|
||||
"quadratic peak interpolation should recover sub-bin frequency");
|
||||
require(std::abs(peak->dbfs + 12.0f) < 0.1f,
|
||||
"peak readout should preserve the untilted dBFS value");
|
||||
require(peak->pitch.find("A4") == 0,
|
||||
"peak readout should include its musical pitch");
|
||||
require(Prism::Tui::formatSpectrumPitch(261.6256f).find("C4") == 0,
|
||||
"pitch formatting should use conventional note and octave names");
|
||||
|
||||
tracker.reset();
|
||||
std::fill(magnitudes.begin(), magnitudes.end(), -100.0f);
|
||||
require(!tracker.select(magnitudes, sampleRate, fftSize, 2.0f),
|
||||
"silent spectra should not produce a peak readout");
|
||||
}
|
||||
|
||||
void testSettingsModelAndPersistence() {
|
||||
Prism::Tui::TuiSettings settings;
|
||||
settings.inputTrimDb = 30.0f;
|
||||
settings.refreshRate = 42;
|
||||
settings.spectrumTiltDbPerOctave = -8.0f;
|
||||
settings.oscilloscopeTraceWeight = 20;
|
||||
settings = Prism::Tui::normalizeSettings(settings);
|
||||
require(settings.inputTrimDb == 12.0f && settings.refreshRate == 60 &&
|
||||
settings.spectrumTiltDbPerOctave == -2.0f &&
|
||||
settings.oscilloscopeTraceWeight == 3,
|
||||
"settings normalization should enforce public ranges");
|
||||
|
||||
const auto pages = Prism::Tui::settingsPages();
|
||||
require(pages.size() == 4 &&
|
||||
Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 3,
|
||||
"settings should expose shallow category pages");
|
||||
Prism::Tui::TuiSettings adjusted;
|
||||
require(Prism::Tui::adjustSetting(
|
||||
adjusted, Prism::Tui::SettingId::InputTrim, 1) &&
|
||||
adjusted.inputTrimDb == 0.5f,
|
||||
"numeric settings should adjust by their documented step");
|
||||
require(Prism::Tui::adjustSetting(
|
||||
adjusted, Prism::Tui::SettingId::SpectrumPeakReadout, 1) &&
|
||||
!adjusted.spectrumPeakReadout,
|
||||
"boolean settings should toggle directly");
|
||||
require(Prism::Tui::adjustSetting(
|
||||
adjusted, Prism::Tui::SettingId::OscilloscopePitchLock, 1) &&
|
||||
!adjusted.oscilloscopePitchLock &&
|
||||
!adjusted.oscilloscopeFrequencyReadout,
|
||||
"disabling pitch lock should also disable its frequency readout");
|
||||
require(!Prism::Tui::adjustSetting(
|
||||
adjusted, Prism::Tui::SettingId::OscilloscopeFrequencyReadout, 1) &&
|
||||
!adjusted.oscilloscopeFrequencyReadout,
|
||||
"frequency readout should remain unavailable without pitch lock");
|
||||
require(Prism::Tui::adjustSetting(
|
||||
adjusted, Prism::Tui::SettingId::OscilloscopePitchLock, 1) &&
|
||||
adjusted.oscilloscopePitchLock,
|
||||
"pitch lock should remain independently re-enableable");
|
||||
require(Prism::Tui::resetSetting(
|
||||
adjusted, Prism::Tui::SettingId::InputTrim) &&
|
||||
adjusted.inputTrimDb == 0.0f,
|
||||
"individual settings should reset to defaults");
|
||||
|
||||
const auto settingsPath = std::filesystem::temp_directory_path() /
|
||||
"prism-tui-settings-test.conf";
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(settingsPath, ignored);
|
||||
adjusted.layoutPreset = Prism::Tui::LayoutPreset::Columns;
|
||||
adjusted.vectorscopeMode = Prism::Tui::VectorscopeMode::PolarBipolar;
|
||||
adjusted.vectorscopeDetail = Prism::Tui::VectorscopeDetail::Maximum;
|
||||
std::string error;
|
||||
require(Prism::Tui::saveSettings(adjusted, settingsPath, &error),
|
||||
"settings should persist to a TUI-specific configuration file");
|
||||
require(Prism::Tui::loadSettings(settingsPath) == adjusted,
|
||||
"persisted settings should round-trip without changing values");
|
||||
std::filesystem::remove(settingsPath, ignored);
|
||||
}
|
||||
|
||||
void testScopePlotModels() {
|
||||
const auto oscilloscope = Prism::Tui::buildOscilloscopePlot(
|
||||
{-1.0f, 0.0f, 1.0f}, 9, 9);
|
||||
require(oscilloscope.size() == 9,
|
||||
"oscilloscope projection should fill every Braille pixel column");
|
||||
require(oscilloscope.front().y == 8 && oscilloscope.back().y == 0,
|
||||
"oscilloscope projection should preserve full-scale polarity");
|
||||
require(std::all_of(
|
||||
oscilloscope.begin(), oscilloscope.end(), [](const Prism::Tui::PlotPoint& point) {
|
||||
return point.x >= 0 && point.x < 9 && point.y >= 0 && point.y < 9;
|
||||
}), "oscilloscope projection should remain bounded");
|
||||
const auto zeroLine = Prism::Tui::buildOscilloscopePlot({0.0f}, 1, 8);
|
||||
require(zeroLine.front().y == Prism::Tui::oscilloscopeZeroY(8) &&
|
||||
zeroLine.front().y == 4,
|
||||
"the oscilloscope zero line should use the waveform's center rounding");
|
||||
|
||||
const std::vector<float> multiband = {
|
||||
1.0f, 0.0f,
|
||||
0.0f, 0.5f,
|
||||
-1.0f, -0.5f,
|
||||
};
|
||||
const auto vectorscope = Prism::Tui::buildVectorscopePlot(
|
||||
multiband, 1, 21, 21);
|
||||
require(vectorscope[0].size() == 1 &&
|
||||
vectorscope[1].size() == 1 &&
|
||||
vectorscope[2].size() == 1,
|
||||
"vectorscope projection should preserve all three frequency bands");
|
||||
for (const auto& band : vectorscope) {
|
||||
require(std::all_of(
|
||||
band.begin(), band.end(), [](const Prism::Tui::PlotPoint& point) {
|
||||
return point.x >= 0 && point.x < 21 && point.y >= 0 && point.y < 21;
|
||||
}), "vectorscope projection should remain bounded");
|
||||
}
|
||||
require(Prism::Tui::buildOscilloscopePlot({}, 10, 10).empty(),
|
||||
"an empty oscilloscope frame should render no points");
|
||||
|
||||
auto mode = Prism::Tui::VectorscopeMode::Lissajous;
|
||||
for (int index = 0; index < 5; ++index) {
|
||||
require(std::string(Prism::Tui::vectorscopeModeName(mode)).size() > 0,
|
||||
"each vectorscope mode should have a display name");
|
||||
mode = Prism::Tui::nextVectorscopeMode(mode);
|
||||
}
|
||||
require(mode == Prism::Tui::VectorscopeMode::Lissajous,
|
||||
"vectorscope mode selection should cycle through all five modes");
|
||||
|
||||
const std::vector<float> correlated = {
|
||||
0.25f, 0.25f,
|
||||
0.25f, 0.25f,
|
||||
0.25f, 0.25f,
|
||||
};
|
||||
const auto linear = Prism::Tui::buildVectorscopePlot(
|
||||
correlated,
|
||||
1,
|
||||
41,
|
||||
41,
|
||||
Prism::Tui::VectorscopeMode::LinearBipolar);
|
||||
const auto polar = Prism::Tui::buildVectorscopePlot(
|
||||
correlated,
|
||||
1,
|
||||
41,
|
||||
41,
|
||||
Prism::Tui::VectorscopeMode::PolarBipolar);
|
||||
const auto centeredLayout = Prism::Tui::getVectorscopePlotLayout(
|
||||
41, 41, Prism::Tui::VectorscopeMode::LinearBipolar);
|
||||
require(linear[0].front().x == centeredLayout.centerX &&
|
||||
linear[0].front().y < centeredLayout.centerY,
|
||||
"linear vectorscope mode should rotate correlated stereo onto the mono axis");
|
||||
require(polar[0].front().y < linear[0].front().y,
|
||||
"polar vectorscope mode should expand quiet points radially");
|
||||
|
||||
const std::vector<float> negativeMid = {
|
||||
-0.5f, -0.5f,
|
||||
-0.5f, -0.5f,
|
||||
-0.5f, -0.5f,
|
||||
};
|
||||
const auto unipolar = Prism::Tui::buildVectorscopePlot(
|
||||
negativeMid,
|
||||
1,
|
||||
41,
|
||||
41,
|
||||
Prism::Tui::VectorscopeMode::PolarUnipolar);
|
||||
require(unipolar[0].empty() && unipolar[1].empty() && unipolar[2].empty(),
|
||||
"unipolar vectorscope modes should omit negative-mid points");
|
||||
const auto unipolarLayout = Prism::Tui::getVectorscopePlotLayout(
|
||||
41, 41, Prism::Tui::VectorscopeMode::PolarUnipolar);
|
||||
require(unipolarLayout.unipolar &&
|
||||
unipolarLayout.centerY > centeredLayout.centerY,
|
||||
"unipolar vectorscope modes should use the lower display origin");
|
||||
|
||||
std::vector<float> denseMultiband(300 * Visualizer::MULTIBAND_POINT_STRIDE, 0.2f);
|
||||
const auto detailPreserving = Prism::Tui::buildVectorscopePlot(
|
||||
denseMultiband,
|
||||
300,
|
||||
12,
|
||||
12,
|
||||
Prism::Tui::VectorscopeMode::Lissajous);
|
||||
for (const auto& band : detailPreserving) {
|
||||
require(band.size() <= 64,
|
||||
"vectorscope projection should adapt its point budget to terminal resolution");
|
||||
require(!band.empty() && band.front().intensity < band.back().intensity &&
|
||||
band.back().intensity == 1.0f,
|
||||
"vectorscope projection should retain chronological intensity information");
|
||||
}
|
||||
const auto balancedDetail = Prism::Tui::buildVectorscopePlot(
|
||||
denseMultiband, 300, 30, 30, Prism::Tui::VectorscopeMode::Lissajous, 10);
|
||||
const auto maximumDetail = Prism::Tui::buildVectorscopePlot(
|
||||
denseMultiband, 300, 30, 30, Prism::Tui::VectorscopeMode::Lissajous, 3);
|
||||
require(balancedDetail[0].size() < maximumDetail[0].size(),
|
||||
"vectorscope detail settings should change the adaptive point budget");
|
||||
}
|
||||
|
||||
void testPitchReadoutResponse() {
|
||||
constexpr float sampleRate = 48000.0f;
|
||||
Visualizer::Oscilloscope oscilloscope;
|
||||
oscilloscope.setSampleRate(sampleRate);
|
||||
oscilloscope.setPitchLock(true);
|
||||
|
||||
const auto lowTone = sineChunk(100.0f, 0.5f, 4096, sampleRate);
|
||||
oscilloscope.pushSamples(lowTone.left.data(), lowTone.left.size());
|
||||
for (int index = 0; index < 24; ++index) {
|
||||
oscilloscope.process();
|
||||
}
|
||||
|
||||
const auto highTone = sineChunk(400.0f, 0.5f, 4096, sampleRate);
|
||||
oscilloscope.pushSamples(highTone.left.data(), highTone.left.size());
|
||||
const auto locked = oscilloscope.process();
|
||||
const float latest = oscilloscope.getLatestDetectedPitch();
|
||||
require(latest > 0.0f &&
|
||||
std::abs(latest - 400.0f) < std::abs(locked.detectedPitch - 400.0f),
|
||||
"the fast pitch readout should respond before the stable trigger pitch");
|
||||
|
||||
Visualizer::Oscilloscope freeRunning;
|
||||
freeRunning.setSampleRate(sampleRate);
|
||||
freeRunning.setPitchLock(false);
|
||||
freeRunning.setDisplaySamples(128);
|
||||
const auto firstChunk = sineChunk(200.0f, 0.5f, 512, sampleRate);
|
||||
freeRunning.pushSamples(firstChunk.left.data(), firstChunk.left.size());
|
||||
const auto firstWindow = freeRunning.process();
|
||||
require(firstWindow.triggerIndex == 384.0f,
|
||||
"free-running oscilloscopes should show the newest complete window");
|
||||
const auto nextChunk = sineChunk(200.0f, 0.5f, 64, sampleRate);
|
||||
freeRunning.pushSamples(nextChunk.left.data(), nextChunk.left.size());
|
||||
const auto nextWindow = freeRunning.process();
|
||||
require(nextWindow.triggerIndex == 448.0f &&
|
||||
nextWindow.triggerIndex != firstWindow.triggerIndex,
|
||||
"free-running oscilloscope windows should advance with every audio chunk");
|
||||
}
|
||||
|
||||
void testPipelineAndFakeCapture() {
|
||||
@@ -216,6 +471,26 @@ void testPipelineAndFakeCapture() {
|
||||
const auto frame = pipeline.snapshot();
|
||||
require(frame.magnitudes.size() == Prism::Tui::kDefaultFftSize / 2,
|
||||
"the default analysis pipeline should publish the 4096-point spectrum");
|
||||
require(frame.oscilloscope.samples.size() == 2048 &&
|
||||
frame.oscilloscope.signalPresent,
|
||||
"the pipeline should publish a live pitch-locked oscilloscope window");
|
||||
require(std::isfinite(frame.oscilloscope.detectedPitch) &&
|
||||
frame.oscilloscope.detectedPitch > 0.0f,
|
||||
"the pipeline should publish the fast pitch readout");
|
||||
require(std::all_of(
|
||||
frame.oscilloscope.samples.begin(),
|
||||
frame.oscilloscope.samples.end(),
|
||||
[](float sample) { return std::isfinite(sample); }),
|
||||
"oscilloscope samples should remain finite");
|
||||
require(frame.vectorscope.pointCount == Prism::Tui::kVectorscopeDisplayPoints &&
|
||||
frame.vectorscope.multibandPoints.size() ==
|
||||
frame.vectorscope.pointCount * Visualizer::MULTIBAND_POINT_STRIDE,
|
||||
"the pipeline should publish a full multiband vectorscope frame");
|
||||
require(std::all_of(
|
||||
frame.vectorscope.multibandPoints.begin(),
|
||||
frame.vectorscope.multibandPoints.end(),
|
||||
[](float sample) { return std::isfinite(sample); }),
|
||||
"vectorscope samples should remain finite");
|
||||
require(frame.vu.barLDb > -20.0f && frame.vu.barLDb < -5.0f,
|
||||
"VU level should reflect deterministic input");
|
||||
require(std::isfinite(frame.lufs.momentaryLUFS) && frame.lufs.momentaryLUFS > -60.0f,
|
||||
@@ -225,6 +500,17 @@ void testPipelineAndFakeCapture() {
|
||||
require(std::abs(frame.lufs.integratedLUFS + 12.03f) < 0.5f,
|
||||
"integrated LUFS should match the deterministic stereo tone");
|
||||
|
||||
Prism::Tui::AnalysisPipeline trimmedPipeline(48000.0f);
|
||||
trimmedPipeline.setInputTrimDb(6.0f);
|
||||
for (int index = 0; index < 20; ++index) {
|
||||
trimmedPipeline.process(sineChunk(1000.0f, 0.25f, 2400, 48000.0f));
|
||||
}
|
||||
const auto trimmed = trimmedPipeline.snapshot();
|
||||
require(std::abs((trimmed.vu.barLDb - frame.vu.barLDb) - 6.0f) < 0.35f,
|
||||
"input trim should affect the real VU analyzer before processing");
|
||||
require(std::abs((trimmed.lufs.momentaryLUFS - frame.lufs.momentaryLUFS) - 6.0f) < 0.35f,
|
||||
"input trim should affect the real loudness analyzer before processing");
|
||||
|
||||
Prism::Tui::AnalysisPipeline stereoPipeline(48000.0f);
|
||||
for (int index = 0; index < 20; ++index) {
|
||||
stereoPipeline.process(stereoSineChunk(1000.0f, 0.5f, 0.125f, 2400, 48000.0f));
|
||||
@@ -235,6 +521,12 @@ void testPipelineAndFakeCapture() {
|
||||
pipeline.reset();
|
||||
const auto reset = pipeline.snapshot();
|
||||
require(reset.lufs.integratedLUFS <= -59.0f, "reset should clear integrated loudness");
|
||||
require(!reset.oscilloscope.signalPresent,
|
||||
"reset should clear the oscilloscope display window");
|
||||
require(reset.oscilloscope.detectedPitch == 0.0f,
|
||||
"reset should clear the fast pitch readout");
|
||||
require(reset.vectorscope.pointCount == 0 && reset.vectorscope.multibandPoints.empty(),
|
||||
"reset should clear vectorscope history");
|
||||
require(capture.stopped, "fake capture should stop cleanly");
|
||||
}
|
||||
|
||||
@@ -260,6 +552,10 @@ void testThreadSafeSnapshots() {
|
||||
int main() {
|
||||
testCli();
|
||||
testProjectionAndLayout();
|
||||
testSpectrumPeakModel();
|
||||
testSettingsModelAndPersistence();
|
||||
testScopePlotModels();
|
||||
testPitchReadoutResponse();
|
||||
testPipelineAndFakeCapture();
|
||||
testThreadSafeSnapshots();
|
||||
std::cout << "Prism TUI tests passed\n";
|
||||
|
||||
Reference in New Issue
Block a user