spectrogram and waveform

This commit is contained in:
Boof2015
2026-08-14 20:58:44 -04:00
parent 5910005dd4
commit fbec2a5aa8
12 changed files with 943 additions and 11 deletions
+3
View File
@@ -34,6 +34,7 @@ add_library(prism_tui_analysis STATIC
src/dashboard_layout.cpp
src/display_model.cpp
src/meter_display_model.cpp
src/scrolling_history.cpp
src/scope_plot_model.cpp
src/spectrum_peak_model.cpp
src/tui_settings.cpp
@@ -43,6 +44,8 @@ add_library(prism_tui_analysis STATIC
${PRISM_NATIVE_DIR}/multiband.cpp
${PRISM_NATIVE_DIR}/vumeter.cpp
${PRISM_NATIVE_DIR}/lufsmeter.cpp
${PRISM_NATIVE_DIR}/spectrogram.cpp
${PRISM_NATIVE_DIR}/waveform.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(prism_tui_analysis PUBLIC
src
+80 -1
View File
@@ -25,7 +25,13 @@ int normalizedOscilloscopeDisplaySamples(float sampleRate) {
} // namespace
AnalysisPipeline::AnalysisPipeline(float sampleRate, size_t fftSize)
: spectrum_(fftSize), sampleRate_(sampleRate), fftSize_(fftSize) {
: spectrum_(fftSize),
spectrogramDisplayHistory_(kSpectrogramHistoryRows, kSpectrogramHistoryColumns),
spectrogramHeatHistory_(kSpectrogramHistoryRows, kSpectrogramHistoryColumns),
waveformHistory_(Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE,
kWaveformHistoryColumns),
sampleRate_(sampleRate),
fftSize_(fftSize) {
spectrum_.setSampleRate(sampleRate);
spectrum_.setSmoothing(0.9f);
vu_.setSampleRate(sampleRate);
@@ -34,6 +40,8 @@ AnalysisPipeline::AnalysisPipeline(float sampleRate, size_t fftSize)
oscilloscope_.setPitchLock(true);
oscilloscope_.setDisplaySamples(normalizedOscilloscopeDisplaySamples(sampleRate));
vectorscope_.setSampleRate(sampleRate);
setSpectrogramSettings(2.0f, 1.0f, 4.0f, "sharper", "log", "horizontal");
setWaveformSettings(false, 1);
}
void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) {
@@ -62,6 +70,30 @@ void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) {
}
oscilloscope_.pushSamples(monoScratch_.data(), count);
vectorscope_.pushMultibandSamples(left, right, count);
const auto spectrogramColumns = spectrogram_.process(monoScratch_.data(), count);
spectrogramDisplayHistory_.append(spectrogramColumns.display);
spectrogramHeatHistory_.append(spectrogramColumns.heat);
if (waveformStereo_) {
const auto& columns = waveform_.processStereo(left, right, count);
waveformHistory_.append(columns);
} else {
const auto& columns = waveform_.processMono(monoScratch_.data(), count);
const size_t columnCount = columns.size() / Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE;
waveformMonoScratch_.resize(
columnCount * Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE);
for (size_t column = 0; column < columnCount; ++column) {
const size_t source = column * Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE;
const size_t destination = column * Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE;
for (size_t value = 0; value < Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE; ++value) {
waveformMonoScratch_[destination + value] = columns[source + value];
waveformMonoScratch_[destination + Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE + value] =
columns[source + value];
}
}
waveformHistory_.append(waveformMonoScratch_);
}
}
AnalysisFrame AnalysisPipeline::snapshot() {
@@ -102,6 +134,10 @@ AnalysisFrame AnalysisPipeline::snapshot() {
kVectorscopeDisplayPoints);
frame.vectorscope.multibandPoints.resize(
frame.vectorscope.pointCount * Visualizer::MULTIBAND_POINT_STRIDE);
frame.spectrogram.display = spectrogramDisplayHistory_.snapshot();
frame.spectrogram.heat = spectrogramHeatHistory_.snapshot();
frame.waveform.history = waveformHistory_.snapshot();
frame.waveform.stereo = waveformStereo_;
return frame;
}
@@ -111,10 +147,16 @@ void AnalysisPipeline::reset() {
lufs_.reset();
oscilloscope_.reset();
vectorscope_.reset();
spectrogram_.reset();
waveform_.reset();
spectrogramDisplayHistory_.reset();
spectrogramHeatHistory_.reset();
waveformHistory_.reset();
spectrumPeakTracker_.reset();
monoScratch_.clear();
trimmedLeftScratch_.clear();
trimmedRightScratch_.clear();
waveformMonoScratch_.clear();
displayPitch_ = 0.0f;
}
@@ -133,6 +175,43 @@ void AnalysisPipeline::setOscilloscopePitchLock(bool enabled) {
oscilloscope_.setPitchLock(enabled);
}
void AnalysisPipeline::setSpectrogramSettings(float scrollSpeed,
float contrast,
float tiltDbPerOctave,
const std::string& clarityMode,
const std::string& scaleMode,
const std::string& orientation) {
Visualizer::SpectrogramConfig config;
config.fftSize = fftSize_;
config.sampleRate = sampleRate_;
config.rowCount = kSpectrogramHistoryRows;
config.minFrequency = 20.0f;
config.maxFrequency = 20000.0f;
config.minDecibels = -90.0f;
config.maxDecibels = -12.0f;
config.scrollSpeed = std::clamp(scrollSpeed, 0.5f, 4.0f);
config.contrast = std::clamp(contrast, 0.5f, 2.0f);
config.tiltDbPerOctave = std::clamp(tiltDbPerOctave, -2.0f, 8.0f);
config.clarityMode = clarityMode;
config.scaleMode = scaleMode;
config.orientation = orientation;
spectrogram_.configure(config);
spectrogramDisplayHistory_.reset();
spectrogramHeatHistory_.reset();
}
void AnalysisPipeline::setWaveformSettings(bool stereo, int scrollSpeed) {
waveformStereo_ = stereo;
waveformScrollSpeed_ = std::clamp(scrollSpeed, 1, 8);
constexpr float baseColumnsPerSecond = 128.0f;
const size_t samplesPerColumn = static_cast<size_t>(std::max(
1.0f,
std::round(sampleRate_ /
(baseColumnsPerSecond * static_cast<float>(waveformScrollSpeed_)))));
waveform_.configure(sampleRate_, samplesPerColumn);
waveformHistory_.reset();
}
size_t drainCapture(Prism::Capture::SystemAudioCapture& capture,
AnalysisPipeline& pipeline,
bool& captureOverrun,
+35 -1
View File
@@ -2,19 +2,26 @@
#include "lufsmeter.h"
#include "oscilloscope.h"
#include "scrolling_history.h"
#include "spectrogram.h"
#include "spectrum.h"
#include "spectrum_peak_model.h"
#include "system_audio_capture.h"
#include "vectorscope.h"
#include "vumeter.h"
#include "waveform.h"
#include <vector>
#include <optional>
#include <string>
#include <vector>
namespace Prism::Tui {
constexpr size_t kDefaultFftSize = 4096;
constexpr size_t kVectorscopeDisplayPoints = 4096;
constexpr size_t kSpectrogramHistoryRows = 128;
constexpr size_t kSpectrogramHistoryColumns = 1024;
constexpr size_t kWaveformHistoryColumns = 2048;
struct OscilloscopeFrame {
std::vector<float> samples;
@@ -27,6 +34,16 @@ struct VectorscopeFrame {
size_t pointCount = 0;
};
struct SpectrogramFrame {
ScrollingHistoryFrame display;
ScrollingHistoryFrame heat;
};
struct WaveformFrame {
ScrollingHistoryFrame history;
bool stereo = false;
};
struct AnalysisFrame {
std::vector<float> magnitudes;
std::optional<SpectrumPeakInfo> spectrumPeak;
@@ -34,6 +51,8 @@ struct AnalysisFrame {
Visualizer::LUFSMeterSnapshot lufs{};
OscilloscopeFrame oscilloscope;
VectorscopeFrame vectorscope;
SpectrogramFrame spectrogram;
WaveformFrame waveform;
};
class AnalysisPipeline {
@@ -46,6 +65,13 @@ public:
void setInputTrimDb(float db);
void setSpectrumTilt(float dbPerOctave);
void setOscilloscopePitchLock(bool enabled);
void setSpectrogramSettings(float scrollSpeed,
float contrast,
float tiltDbPerOctave,
const std::string& clarityMode,
const std::string& scaleMode,
const std::string& orientation);
void setWaveformSettings(bool stereo, int scrollSpeed);
private:
Visualizer::Spectrum spectrum_;
@@ -53,15 +79,23 @@ private:
Visualizer::LUFSMeterAnalyzer lufs_;
Visualizer::Oscilloscope oscilloscope_;
Visualizer::Vectorscope vectorscope_;
Visualizer::SpectrogramAnalyzer spectrogram_;
Visualizer::WaveformMultibandAnalyzer waveform_;
ScrollingHistory spectrogramDisplayHistory_;
ScrollingHistory spectrogramHeatHistory_;
ScrollingHistory waveformHistory_;
SpectrumPeakTracker spectrumPeakTracker_;
std::vector<float> monoScratch_;
std::vector<float> trimmedLeftScratch_;
std::vector<float> trimmedRightScratch_;
std::vector<float> waveformMonoScratch_;
float sampleRate_ = 48000.0f;
size_t fftSize_ = kDefaultFftSize;
float inputGainLinear_ = 1.0f;
float spectrumTiltDbPerOctave_ = 2.0f;
float displayPitch_ = 0.0f;
bool waveformStereo_ = false;
int waveformScrollSpeed_ = 1;
};
size_t drainCapture(Prism::Capture::SystemAudioCapture& capture,
+2 -1
View File
@@ -60,10 +60,11 @@ std::string usageText() {
"Controls:\n"
" Tab / Shift-Tab Focus the next or previous panel.\n"
" Enter Expand the focused panel or restore the dashboard.\n"
" s Open settings for the focused scope.\n"
" s Open settings.\n"
" l Cycle automatic, stacked, and column layouts.\n"
" v Cycle vectorscope display modes.\n"
" 1 / 2 / 3 / 4 / 5 Focus Spectrum, Oscilloscope, Vectorscope, VU, or LUFS.\n"
" 6 / 7 Focus Spectrogram or Waveform.\n"
" r Reset analyzers and integrated loudness.\n"
" q / Esc / Ctrl-C Quit.\n";
}
+21
View File
@@ -24,6 +24,10 @@ MinimumSize panelMinimumSize(PanelId panel) {
return {30, 5};
case PanelId::LUFSMeter:
return {30, 7};
case PanelId::Spectrogram:
return {30, 8};
case PanelId::Waveform:
return {30, 7};
}
return {1, 1};
}
@@ -155,6 +159,21 @@ LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) {
LayoutNode makeRoot(LayoutPreset preset, int width, int height) {
if (preset == LayoutPreset::Columns) {
if (width >= 108 && height >= 34) {
return LayoutNode::split(SplitAxis::Columns, {
LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Spectrum, 3),
LayoutNode::leaf(PanelId::Oscilloscope, 2),
LayoutNode::leaf(PanelId::Waveform, 2),
}, 3),
LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Vectorscope, 2),
LayoutNode::leaf(PanelId::VUMeter, 1),
LayoutNode::leaf(PanelId::LUFSMeter, 1),
LayoutNode::leaf(PanelId::Spectrogram, 2),
}, 1),
});
}
return LayoutNode::split(SplitAxis::Columns, {
LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Spectrum, 3),
@@ -253,6 +272,8 @@ std::vector<PanelId> panelOrder() {
PanelId::Vectorscope,
PanelId::VUMeter,
PanelId::LUFSMeter,
PanelId::Spectrogram,
PanelId::Waveform,
};
}
+2
View File
@@ -12,6 +12,8 @@ enum class PanelId {
Vectorscope,
VUMeter,
LUFSMeter,
Spectrogram,
Waveform,
};
enum class SplitAxis {
+71
View File
@@ -0,0 +1,71 @@
#include "scrolling_history.h"
#include <algorithm>
#include <stdexcept>
namespace Prism::Tui {
ScrollingHistory::ScrollingHistory(size_t columnStride, size_t capacityColumns)
: columnStride_(columnStride),
capacityColumns_(capacityColumns),
values_(columnStride * capacityColumns, 0.0f) {
if (columnStride == 0 || capacityColumns == 0) {
throw std::invalid_argument("scrolling history dimensions must be non-zero");
}
}
void ScrollingHistory::append(const float* values, size_t columnCount) {
if (values == nullptr || columnCount == 0) {
return;
}
if (columnCount >= capacityColumns_) {
values += (columnCount - capacityColumns_) * columnStride_;
columnCount = capacityColumns_;
std::copy(values,
values + columnCount * columnStride_,
values_.begin());
columnCount_ = capacityColumns_;
writeColumn_ = 0;
return;
}
for (size_t column = 0; column < columnCount; ++column) {
const size_t destination = writeColumn_ * columnStride_;
const size_t source = column * columnStride_;
std::copy(values + source,
values + source + columnStride_,
values_.begin() + static_cast<std::ptrdiff_t>(destination));
writeColumn_ = (writeColumn_ + 1) % capacityColumns_;
columnCount_ = std::min(columnCount_ + 1, capacityColumns_);
}
}
void ScrollingHistory::append(const std::vector<float>& values) {
append(values.data(), values.size() / columnStride_);
}
ScrollingHistoryFrame ScrollingHistory::snapshot() const {
ScrollingHistoryFrame frame;
frame.columnCount = columnCount_;
frame.columnStride = columnStride_;
frame.values.resize(columnCount_ * columnStride_);
if (columnCount_ == 0) {
return frame;
}
const size_t oldest = columnCount_ == capacityColumns_ ? writeColumn_ : 0;
for (size_t column = 0; column < columnCount_; ++column) {
const size_t sourceColumn = (oldest + column) % capacityColumns_;
std::copy(values_.begin() + static_cast<std::ptrdiff_t>(sourceColumn * columnStride_),
values_.begin() + static_cast<std::ptrdiff_t>((sourceColumn + 1) * columnStride_),
frame.values.begin() + static_cast<std::ptrdiff_t>(column * columnStride_));
}
return frame;
}
void ScrollingHistory::reset() {
columnCount_ = 0;
writeColumn_ = 0;
}
} // namespace Prism::Tui
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <cstddef>
#include <vector>
namespace Prism::Tui {
struct ScrollingHistoryFrame {
std::vector<float> values;
size_t columnCount = 0;
size_t columnStride = 0;
};
// Fixed-capacity column storage. Audio processing appends into the ring while
// snapshots expose a stable, chronological copy to the renderer.
class ScrollingHistory {
public:
ScrollingHistory(size_t columnStride, size_t capacityColumns);
void append(const float* values, size_t columnCount);
void append(const std::vector<float>& values);
ScrollingHistoryFrame snapshot() const;
void reset();
size_t columnCount() const { return columnCount_; }
size_t columnStride() const { return columnStride_; }
private:
size_t columnStride_ = 0;
size_t capacityColumns_ = 0;
size_t columnCount_ = 0;
size_t writeColumn_ = 0;
std::vector<float> values_;
};
} // namespace Prism::Tui
+330 -3
View File
@@ -47,6 +47,48 @@ std::chrono::microseconds displayFrameInterval(int framesPerSecond) {
1000000 / std::max(1, framesPerSecond));
}
const char* spectrogramClarityConfig(SpectrogramClarity clarity) {
switch (clarity) {
case SpectrogramClarity::Classic: return "classic";
case SpectrogramClarity::Sharp: return "sharp";
case SpectrogramClarity::Sharper: return "sharper";
}
return "sharper";
}
const char* spectrogramScaleConfig(SpectrogramScale scale) {
switch (scale) {
case SpectrogramScale::Mel: return "mel";
case SpectrogramScale::Logarithmic: return "log";
case SpectrogramScale::Linear: return "linear";
}
return "log";
}
const char* spectrogramOrientationConfig(SpectrogramOrientation orientation) {
return orientation == SpectrogramOrientation::Vertical
? "vertical"
: "horizontal";
}
void applySpectrogramSettings(AnalysisPipeline& pipeline,
const TuiSettings& settings) {
pipeline.setSpectrogramSettings(
settings.spectrogramScrollSpeed,
settings.spectrogramContrast,
settings.spectrogramTiltDbPerOctave,
spectrogramClarityConfig(settings.spectrogramClarity),
spectrogramScaleConfig(settings.spectrogramScale),
spectrogramOrientationConfig(settings.spectrogramOrientation));
}
void applyWaveformSettings(AnalysisPipeline& pipeline,
const TuiSettings& settings) {
pipeline.setWaveformSettings(
settings.waveformMode == WaveformMode::Stereo,
settings.waveformScrollSpeed);
}
volatile std::sig_atomic_t signalRequested = 0;
void handleSignal(int) {
@@ -77,6 +119,8 @@ struct DisplayFrame {
Visualizer::LUFSMeterSnapshot lufs{};
OscilloscopeFrame oscilloscope;
VectorscopeFrame vectorscope;
SpectrogramFrame spectrogram;
WaveformFrame waveform;
double sampleRate = 48000.0;
std::string backend;
std::string device;
@@ -90,7 +134,7 @@ struct InterfaceState {
bool settingsOpen = false;
SettingsPage settingsPage = SettingsPage::Home;
size_t settingsHomeSelection = 0;
std::array<size_t, 7> settingsSelections{};
std::array<size_t, 9> settingsSelections{};
std::string settingsStatus;
};
@@ -157,6 +201,10 @@ std::string panelName(PanelId panel) {
return "VU Meter";
case PanelId::LUFSMeter:
return "LUFS Meter";
case PanelId::Spectrogram:
return "Spectrogram";
case PanelId::Waveform:
return "Waveform";
}
return "Panel";
}
@@ -173,6 +221,10 @@ std::string panelNumber(PanelId panel) {
return "4";
case PanelId::LUFSMeter:
return "5";
case PanelId::Spectrogram:
return "6";
case PanelId::Waveform:
return "7";
}
return "?";
}
@@ -907,6 +959,247 @@ ftxui::Element renderLUFSMeterPanel(const DisplayFrame& frame,
size(HEIGHT, EQUAL, std::max(1, height));
}
ftxui::Color interpolateColor(const std::array<int, 3>& from,
const std::array<int, 3>& to,
float amount) {
const float t = std::clamp(amount, 0.0f, 1.0f);
return ftxui::Color::RGB(
static_cast<uint8_t>(std::lround(from[0] + (to[0] - from[0]) * t)),
static_cast<uint8_t>(std::lround(from[1] + (to[1] - from[1]) * t)),
static_cast<uint8_t>(std::lround(from[2] + (to[2] - from[2]) * t)));
}
ftxui::Color spectrogramColor(float intensity, SpectrogramColorMode mode) {
const float value = std::clamp(intensity, 0.0f, 1.0f);
if (mode == SpectrogramColorMode::Mono) {
return interpolateColor(
{{5, 12, 14}}, {{102, 255, 255}}, std::pow(value, 0.72f));
}
constexpr std::array<float, 6> stops = {0.0f, 0.16f, 0.36f, 0.58f, 0.78f, 1.0f};
constexpr std::array<std::array<int, 3>, 6> colors = {{
{{5, 3, 12}},
{{15, 7, 33}},
{{61, 11, 94}},
{{163, 26, 121}},
{{255, 82, 87}},
{{255, 241, 209}},
}};
size_t upper = 1;
while (upper + 1 < stops.size() && value > stops[upper]) ++upper;
const size_t lower = upper - 1;
const float span = stops[upper] - stops[lower];
return interpolateColor(
colors[lower], colors[upper],
span > 0.0f ? (value - stops[lower]) / span : 0.0f);
}
float maxHistoryValue(const ScrollingHistoryFrame& history,
size_t column,
size_t firstRow,
size_t lastRow) {
if (column >= history.columnCount || history.columnStride == 0 ||
history.values.size() < history.columnCount * history.columnStride) {
return 0.0f;
}
firstRow = std::min(firstRow, history.columnStride - 1);
lastRow = std::min(std::max(firstRow + 1, lastRow), history.columnStride);
float result = 0.0f;
const size_t offset = column * history.columnStride;
for (size_t row = firstRow; row < lastRow; ++row) {
result = std::max(result, history.values[offset + row]);
}
return result;
}
ftxui::Element renderSpectrogramPanel(const DisplayFrame& frame,
int width,
int height,
bool focused,
const TuiSettings& settings) {
using namespace ftxui;
const bool heat = settings.spectrogramColor == SpectrogramColorMode::Heat;
const auto& history = heat ? frame.spectrogram.heat : frame.spectrogram.display;
const bool vertical =
settings.spectrogramOrientation == SpectrogramOrientation::Vertical;
auto plot = canvas([
history,
colorMode = settings.spectrogramColor,
vertical
](Canvas& surface) {
const int cellColumns = surface.width() / 2;
const int cellRows = surface.height() / 4;
if (cellColumns <= 0 || cellRows <= 0 || history.columnCount == 0 ||
history.columnStride == 0) {
return;
}
const int timeCells = vertical ? cellRows : cellColumns;
const int frequencyCells = vertical ? cellColumns : cellRows;
const size_t visibleColumns = std::min(
history.columnCount, static_cast<size_t>(timeCells));
const size_t sourceStart = history.columnCount - visibleColumns;
const int destinationStart = timeCells - static_cast<int>(visibleColumns);
for (size_t time = 0; time < visibleColumns; ++time) {
const size_t sourceColumn = sourceStart + time;
const int timeCell = destinationStart + static_cast<int>(time);
for (int frequencyCell = 0; frequencyCell < frequencyCells; ++frequencyCell) {
const float normalizedStart = static_cast<float>(frequencyCell) /
static_cast<float>(frequencyCells);
const float normalizedEnd = static_cast<float>(frequencyCell + 1) /
static_cast<float>(frequencyCells);
const size_t firstRow = static_cast<size_t>(std::floor(
normalizedStart * static_cast<float>(history.columnStride)));
const size_t lastRow = static_cast<size_t>(std::ceil(
normalizedEnd * static_cast<float>(history.columnStride)));
const float intensity = maxHistoryValue(
history, sourceColumn, firstRow, lastRow);
if (!std::isfinite(intensity) || intensity < 0.008f) continue;
const int cellX = vertical ? frequencyCell : timeCell;
const int cellY = vertical
? timeCell
: frequencyCell;
const Color color = spectrogramColor(intensity, colorMode);
for (int dx = 0; dx < 2; ++dx) {
surface.DrawBlock(cellX * 2 + dx, cellY * 4, true, color);
surface.DrawBlock(cellX * 2 + dx, cellY * 4 + 2, true, color);
}
}
}
}) | flex;
std::string detail = std::string(spectrogramColorName(settings.spectrogramColor));
if (width >= 48) {
detail += "" + std::string(spectrogramScaleName(settings.spectrogramScale));
}
if (width >= 66) {
detail += "" + std::string(
spectrogramOrientationName(settings.spectrogramOrientation));
}
auto panel = window(
panelTitle(PanelId::Spectrogram, 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));
}
ftxui::Color waveformBandColor(const float* summary, bool multiband) {
using namespace ftxui;
if (!multiband || summary == nullptr) return Color::CyanLight;
std::array<float, 3> weights = {
std::max(0.0f, summary[2]),
std::max(0.0f, summary[3]),
std::max(0.0f, summary[4]),
};
float total = weights[0] + weights[1] + weights[2];
if (total <= 1.0e-8f) return Color::CyanLight;
for (float& weight : weights) {
weight = std::pow(weight / total, 2.6f);
}
total = weights[0] + weights[1] + weights[2];
for (float& weight : weights) {
weight /= std::max(total, 1.0e-8f);
}
constexpr std::array<std::array<int, 3>, 3> colors = {{
{{255, 68, 68}},
{{68, 221, 68}},
{{68, 136, 255}},
}};
std::array<int, 3> mixed{};
for (size_t channel = 0; channel < mixed.size(); ++channel) {
for (size_t band = 0; band < weights.size(); ++band) {
mixed[channel] += static_cast<int>(std::lround(
weights[band] * static_cast<float>(colors[band][channel])));
}
}
return Color::RGB(
std::clamp(mixed[0], 0, 255),
std::clamp(mixed[1], 0, 255),
std::clamp(mixed[2], 0, 255));
}
ftxui::Element renderWaveformPanel(const DisplayFrame& frame,
int width,
int height,
bool focused,
const TuiSettings& settings) {
using namespace ftxui;
const bool stereo = settings.waveformMode == WaveformMode::Stereo;
auto plot = canvas([
history = frame.waveform.history,
stereo,
multiband = settings.waveformMultiband
](Canvas& surface) {
const int canvasWidth = surface.width();
const int canvasHeight = surface.height();
if (canvasWidth <= 0 || canvasHeight <= 0) return;
const int laneCount = stereo ? 2 : 1;
for (int lane = 0; lane < laneCount; ++lane) {
const int centerY = static_cast<int>(std::lround(
(static_cast<float>(lane) + 0.5f) *
static_cast<float>(canvasHeight) / static_cast<float>(laneCount)));
surface.DrawPointLine(
0, centerY, canvasWidth - 1, centerY,
Color::RGB(55, 61, 64));
}
if (stereo) {
surface.DrawPointLine(
0, canvasHeight / 2, canvasWidth - 1, canvasHeight / 2,
Color::RGB(40, 45, 48));
}
if (history.columnCount == 0 ||
history.columnStride < Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE ||
history.values.size() < history.columnCount * history.columnStride) {
return;
}
const size_t visibleColumns = std::min(
history.columnCount, static_cast<size_t>(canvasWidth));
const size_t sourceStart = history.columnCount - visibleColumns;
const int destinationStart = canvasWidth - static_cast<int>(visibleColumns);
const float laneHeight = static_cast<float>(canvasHeight) /
static_cast<float>(laneCount);
const float radius = std::max(1.0f, laneHeight * 0.44f);
for (size_t column = 0; column < visibleColumns; ++column) {
const size_t sourceColumn = sourceStart + column;
const float* summary = history.values.data() +
sourceColumn * history.columnStride;
const int x = destinationStart + static_cast<int>(column);
for (int lane = 0; lane < laneCount; ++lane) {
const float* channel = summary +
(lane == 0 ? 0 : Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE);
const float minimum = std::clamp(channel[0], -1.0f, 1.0f);
const float maximum = std::clamp(channel[1], -1.0f, 1.0f);
const float centerY = (static_cast<float>(lane) + 0.5f) * laneHeight;
const int top = std::clamp(
static_cast<int>(std::lround(centerY - maximum * radius)),
0, canvasHeight - 1);
const int bottom = std::clamp(
static_cast<int>(std::lround(centerY - minimum * radius)),
0, canvasHeight - 1);
surface.DrawBlockLine(
x, std::min(top, bottom), x, std::max(top, bottom),
waveformBandColor(channel, multiband));
}
}
}) | flex;
std::string detail = waveformModeName(settings.waveformMode);
if (settings.waveformMultiband) detail += " • Multiband";
if (width >= 58) {
detail += "" + std::to_string(settings.waveformScrollSpeed) + "×";
}
auto panel = window(
panelTitle(PanelId::Waveform, 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));
}
const PanelRect* findPanelRect(const DashboardLayout& layout, PanelId panel) {
const auto found = std::find_if(
layout.panels.begin(), layout.panels.end(),
@@ -953,6 +1246,20 @@ ftxui::Element renderLayoutNode(const LayoutNode& node,
rect->height,
focused,
state.settings);
case PanelId::Spectrogram:
return renderSpectrogramPanel(
frame,
rect->width,
rect->height,
focused,
state.settings);
case PanelId::Waveform:
return renderWaveformPanel(
frame,
rect->width,
rect->height,
focused,
state.settings);
}
}
@@ -1181,6 +1488,8 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
pipeline.setInputTrimDb(appliedSettings.inputTrimDb);
pipeline.setSpectrumTilt(appliedSettings.spectrumTiltDbPerOctave);
pipeline.setOscilloscopePitchLock(appliedSettings.oscilloscopePitchLock);
applySpectrogramSettings(pipeline, appliedSettings);
applyWaveformSettings(pipeline, appliedSettings);
bool captureOverrun = false;
auto nextFrameAt = std::chrono::steady_clock::now();
@@ -1204,6 +1513,20 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
requestedSettings.spectrumTiltDbPerOctave);
pipeline.setOscilloscopePitchLock(
requestedSettings.oscilloscopePitchLock);
const bool spectrogramAnalysisChanged =
requestedSettings.spectrogramClarity != appliedSettings.spectrogramClarity ||
requestedSettings.spectrogramScale != appliedSettings.spectrogramScale ||
requestedSettings.spectrogramOrientation != appliedSettings.spectrogramOrientation ||
requestedSettings.spectrogramScrollSpeed != appliedSettings.spectrogramScrollSpeed ||
requestedSettings.spectrogramContrast != appliedSettings.spectrogramContrast ||
requestedSettings.spectrogramTiltDbPerOctave != appliedSettings.spectrogramTiltDbPerOctave;
if (spectrogramAnalysisChanged) {
applySpectrogramSettings(pipeline, requestedSettings);
}
if (requestedSettings.waveformMode != appliedSettings.waveformMode ||
requestedSettings.waveformScrollSpeed != appliedSettings.waveformScrollSpeed) {
applyWaveformSettings(pipeline, requestedSettings);
}
appliedSettings = requestedSettings;
if (refreshChanged) {
nextFrameAt = std::chrono::steady_clock::now();
@@ -1222,6 +1545,8 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
next.lufs = analyzed.lufs;
next.oscilloscope = std::move(analyzed.oscilloscope);
next.vectorscope = std::move(analyzed.vectorscope);
next.spectrogram = std::move(analyzed.spectrogram);
next.waveform = std::move(analyzed.waveform);
next.sampleRate = started.sampleRate;
next.backend = capture->backendName();
next.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel;
@@ -1230,8 +1555,8 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
if (running.load() && !redrawQueued.exchange(true)) {
screen.PostEvent(Event::Custom);
}
nextFrameAt = now + displayFrameInterval(
appliedSettings.refreshRate);
nextFrameAt = now +
displayFrameInterval(appliedSettings.refreshRate);
}
std::this_thread::sleep_for(kCapturePollInterval);
}
@@ -1427,6 +1752,8 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
if (event == Event::Character('3')) selectedPanel = PanelId::Vectorscope;
if (event == Event::Character('4')) selectedPanel = PanelId::VUMeter;
if (event == Event::Character('5')) selectedPanel = PanelId::LUFSMeter;
if (event == Event::Character('6')) selectedPanel = PanelId::Spectrogram;
if (event == Event::Character('7')) selectedPanel = PanelId::Waveform;
if (selectedPanel) {
interfaceState.focusedPanel = *selectedPanel;
if (interfaceState.expandedPanel) {
+238 -3
View File
@@ -45,6 +45,22 @@ const std::vector<SettingDescriptor> kLUFSMeterSettings = {
{SettingId::LUFSReadout, "Loudness readout", "Chooses which LUFS window drives the main bar and badge."},
};
const std::vector<SettingDescriptor> kSpectrogramSettings = {
{SettingId::SpectrogramColor, "Color", "Switches between Prism's heat palette and a focused mono display."},
{SettingId::SpectrogramClarity, "Clarity", "Controls frequency reassignment and spectral sharpness."},
{SettingId::SpectrogramScale, "Frequency scale", "Chooses logarithmic, mel, or linear frequency spacing."},
{SettingId::SpectrogramOrientation, "Orientation", "Scrolls time horizontally or vertically."},
{SettingId::SpectrogramScrollSpeed, "Scroll speed", "Changes how quickly new time columns move through the pane."},
{SettingId::SpectrogramContrast, "Contrast", "Shapes the intensity range without changing the analyzer input."},
{SettingId::SpectrogramTilt, "Display tilt", "Offsets frequencies by decibels per octave around 1 kHz."},
};
const std::vector<SettingDescriptor> kWaveformSettings = {
{SettingId::WaveformMode, "Channels", "Shows one mono envelope or separate left and right lanes."},
{SettingId::WaveformScrollSpeed, "Scroll speed", "Changes the time resolution of the rolling waveform."},
{SettingId::WaveformMultiband, "Multiband color", "Colors each envelope column by its dominant frequency band."},
};
float snap(float value, float step) {
return std::round(value / step) * step;
}
@@ -104,6 +120,38 @@ std::string serializeLufsReadout(LUFSReadout readout) {
return "short_term";
}
std::string serializeSpectrogramColor(SpectrogramColorMode mode) {
return mode == SpectrogramColorMode::Mono ? "mono" : "heat";
}
std::string serializeSpectrogramClarity(SpectrogramClarity clarity) {
switch (clarity) {
case SpectrogramClarity::Classic: return "classic";
case SpectrogramClarity::Sharp: return "sharp";
case SpectrogramClarity::Sharper: return "sharper";
}
return "sharper";
}
std::string serializeSpectrogramScale(SpectrogramScale scale) {
switch (scale) {
case SpectrogramScale::Mel: return "mel";
case SpectrogramScale::Logarithmic: return "log";
case SpectrogramScale::Linear: return "linear";
}
return "log";
}
std::string serializeSpectrogramOrientation(SpectrogramOrientation orientation) {
return orientation == SpectrogramOrientation::Vertical
? "vertical"
: "horizontal";
}
std::string serializeWaveformMode(WaveformMode mode) {
return mode == WaveformMode::Stereo ? "stereo" : "mono";
}
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;
@@ -181,6 +229,42 @@ LUFSReadout parseLufsReadout(const std::string& value, LUFSReadout fallback) {
return fallback;
}
SpectrogramColorMode parseSpectrogramColor(
const std::string& value, SpectrogramColorMode fallback) {
if (value == "heat") return SpectrogramColorMode::Heat;
if (value == "mono") return SpectrogramColorMode::Mono;
return fallback;
}
SpectrogramClarity parseSpectrogramClarity(
const std::string& value, SpectrogramClarity fallback) {
if (value == "classic") return SpectrogramClarity::Classic;
if (value == "sharp") return SpectrogramClarity::Sharp;
if (value == "sharper") return SpectrogramClarity::Sharper;
return fallback;
}
SpectrogramScale parseSpectrogramScale(
const std::string& value, SpectrogramScale fallback) {
if (value == "mel") return SpectrogramScale::Mel;
if (value == "log") return SpectrogramScale::Logarithmic;
if (value == "linear") return SpectrogramScale::Linear;
return fallback;
}
SpectrogramOrientation parseSpectrogramOrientation(
const std::string& value, SpectrogramOrientation fallback) {
if (value == "horizontal") return SpectrogramOrientation::Horizontal;
if (value == "vertical") return SpectrogramOrientation::Vertical;
return fallback;
}
WaveformMode parseWaveformMode(const std::string& value, WaveformMode fallback) {
if (value == "mono") return WaveformMode::Mono;
if (value == "stereo") return WaveformMode::Stereo;
return fallback;
}
const char* environmentValue(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] != '\0' ? value : nullptr;
@@ -188,6 +272,38 @@ const char* environmentValue(const char* name) {
} // namespace
const char* spectrogramColorName(SpectrogramColorMode mode) {
return mode == SpectrogramColorMode::Mono ? "Mono" : "Heat";
}
const char* spectrogramClarityName(SpectrogramClarity clarity) {
switch (clarity) {
case SpectrogramClarity::Classic: return "Classic";
case SpectrogramClarity::Sharp: return "Sharp";
case SpectrogramClarity::Sharper: return "Sharper";
}
return "Sharper";
}
const char* spectrogramScaleName(SpectrogramScale scale) {
switch (scale) {
case SpectrogramScale::Mel: return "Mel";
case SpectrogramScale::Logarithmic: return "Log";
case SpectrogramScale::Linear: return "Linear";
}
return "Log";
}
const char* spectrogramOrientationName(SpectrogramOrientation orientation) {
return orientation == SpectrogramOrientation::Vertical
? "Vertical"
: "Horizontal";
}
const char* waveformModeName(WaveformMode mode) {
return mode == WaveformMode::Stereo ? "Stereo" : "Mono";
}
TuiSettings normalizeSettings(TuiSettings settings) {
settings.inputTrimDb = std::clamp(snap(settings.inputTrimDb, 0.5f), -12.0f, 12.0f);
settings.refreshRate = settings.refreshRate <= 30 ? 30 : 60;
@@ -196,6 +312,13 @@ TuiSettings normalizeSettings(TuiSettings settings) {
settings.oscilloscopeTraceWeight = std::clamp(settings.oscilloscopeTraceWeight, 1, 3);
settings.vuReferenceDbfs = std::clamp(
snap(settings.vuReferenceDbfs, 1.0f), -30.0f, 0.0f);
settings.spectrogramScrollSpeed = std::clamp(
snap(settings.spectrogramScrollSpeed, 0.5f), 0.5f, 4.0f);
settings.spectrogramContrast = std::clamp(
snap(settings.spectrogramContrast, 0.1f), 0.5f, 2.0f);
settings.spectrogramTiltDbPerOctave = std::clamp(
snap(settings.spectrogramTiltDbPerOctave, 0.5f), -2.0f, 8.0f);
settings.waveformScrollSpeed = std::clamp(settings.waveformScrollSpeed, 1, 8);
if (!settings.oscilloscopePitchLock) {
settings.oscilloscopeFrequencyReadout = false;
}
@@ -218,7 +341,17 @@ bool operator==(const TuiSettings& left, const TuiSettings& right) {
left.vuMeterOrientation == right.vuMeterOrientation &&
left.vuNeedleChannels == right.vuNeedleChannels &&
left.vuReferenceDbfs == right.vuReferenceDbfs &&
left.lufsReadout == right.lufsReadout;
left.lufsReadout == right.lufsReadout &&
left.spectrogramColor == right.spectrogramColor &&
left.spectrogramClarity == right.spectrogramClarity &&
left.spectrogramScale == right.spectrogramScale &&
left.spectrogramOrientation == right.spectrogramOrientation &&
left.spectrogramScrollSpeed == right.spectrogramScrollSpeed &&
left.spectrogramContrast == right.spectrogramContrast &&
left.spectrogramTiltDbPerOctave == right.spectrogramTiltDbPerOctave &&
left.waveformMode == right.waveformMode &&
left.waveformScrollSpeed == right.waveformScrollSpeed &&
left.waveformMultiband == right.waveformMultiband;
}
bool operator!=(const TuiSettings& left, const TuiSettings& right) {
@@ -233,6 +366,8 @@ std::vector<SettingsPage> settingsPages() {
SettingsPage::Vectorscope,
SettingsPage::VUMeter,
SettingsPage::LUFSMeter,
SettingsPage::Spectrogram,
SettingsPage::Waveform,
};
}
@@ -245,6 +380,8 @@ const char* settingsPageName(SettingsPage page) {
case SettingsPage::Vectorscope: return "Vectorscope";
case SettingsPage::VUMeter: return "VU meter";
case SettingsPage::LUFSMeter: return "LUFS meter";
case SettingsPage::Spectrogram: return "Spectrogram";
case SettingsPage::Waveform: return "Waveform";
}
return "Settings";
}
@@ -258,6 +395,8 @@ const char* settingsPageDescription(SettingsPage page) {
case SettingsPage::Vectorscope: return "Stereo projection and point rendering.";
case SettingsPage::VUMeter: return "Classic level, peak, and phase metering.";
case SettingsPage::LUFSMeter: return "Loudness window and target presentation.";
case SettingsPage::Spectrogram: return "Frequency history, color, and time direction.";
case SettingsPage::Waveform: return "Rolling amplitude envelope and band color.";
}
return {};
}
@@ -270,6 +409,8 @@ const std::vector<SettingDescriptor>& settingsForPage(SettingsPage page) {
case SettingsPage::Vectorscope: return kVectorscopeSettings;
case SettingsPage::VUMeter: return kVUMeterSettings;
case SettingsPage::LUFSMeter: return kLUFSMeterSettings;
case SettingsPage::Spectrogram: return kSpectrogramSettings;
case SettingsPage::Waveform: return kWaveformSettings;
case SettingsPage::Home: break;
}
static const std::vector<SettingDescriptor> empty;
@@ -315,6 +456,26 @@ std::string settingValue(const TuiSettings& settings, SettingId setting) {
return trimFloat(settings.vuReferenceDbfs, 0) + " dBFS";
case SettingId::LUFSReadout:
return lufsReadoutName(settings.lufsReadout);
case SettingId::SpectrogramColor:
return spectrogramColorName(settings.spectrogramColor);
case SettingId::SpectrogramClarity:
return spectrogramClarityName(settings.spectrogramClarity);
case SettingId::SpectrogramScale:
return spectrogramScaleName(settings.spectrogramScale);
case SettingId::SpectrogramOrientation:
return spectrogramOrientationName(settings.spectrogramOrientation);
case SettingId::SpectrogramScrollSpeed:
return trimFloat(settings.spectrogramScrollSpeed, 1) + "×";
case SettingId::SpectrogramContrast:
return trimFloat(settings.spectrogramContrast, 1) + "×";
case SettingId::SpectrogramTilt:
return trimFloat(settings.spectrogramTiltDbPerOctave, 1) + " dB/oct";
case SettingId::WaveformMode:
return waveformModeName(settings.waveformMode);
case SettingId::WaveformScrollSpeed:
return std::to_string(settings.waveformScrollSpeed) + "×";
case SettingId::WaveformMultiband:
return boolValue(settings.waveformMultiband);
}
return {};
}
@@ -323,7 +484,8 @@ bool settingIsBoolean(SettingId setting) {
return setting == SettingId::SpectrumPeakReadout ||
setting == SettingId::OscilloscopePitchLock ||
setting == SettingId::OscilloscopeFrequencyReadout ||
setting == SettingId::VectorscopeGuides;
setting == SettingId::VectorscopeGuides ||
setting == SettingId::WaveformMultiband;
}
bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) {
@@ -412,6 +574,49 @@ bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) {
settings.lufsReadout = static_cast<LUFSReadout>(value);
break;
}
case SettingId::SpectrogramColor:
settings.spectrogramColor = settings.spectrogramColor == SpectrogramColorMode::Heat
? SpectrogramColorMode::Mono
: SpectrogramColorMode::Heat;
break;
case SettingId::SpectrogramClarity: {
int value = static_cast<int>(settings.spectrogramClarity);
value = (value + (direction > 0 ? 1 : 2)) % 3;
settings.spectrogramClarity = static_cast<SpectrogramClarity>(value);
break;
}
case SettingId::SpectrogramScale: {
int value = static_cast<int>(settings.spectrogramScale);
value = (value + (direction > 0 ? 1 : 2)) % 3;
settings.spectrogramScale = static_cast<SpectrogramScale>(value);
break;
}
case SettingId::SpectrogramOrientation:
settings.spectrogramOrientation =
settings.spectrogramOrientation == SpectrogramOrientation::Horizontal
? SpectrogramOrientation::Vertical
: SpectrogramOrientation::Horizontal;
break;
case SettingId::SpectrogramScrollSpeed:
settings.spectrogramScrollSpeed += direction > 0 ? 0.5f : -0.5f;
break;
case SettingId::SpectrogramContrast:
settings.spectrogramContrast += direction > 0 ? 0.1f : -0.1f;
break;
case SettingId::SpectrogramTilt:
settings.spectrogramTiltDbPerOctave += direction > 0 ? 0.5f : -0.5f;
break;
case SettingId::WaveformMode:
settings.waveformMode = settings.waveformMode == WaveformMode::Mono
? WaveformMode::Stereo
: WaveformMode::Mono;
break;
case SettingId::WaveformScrollSpeed:
settings.waveformScrollSpeed += direction > 0 ? 1 : -1;
break;
case SettingId::WaveformMultiband:
settings.waveformMultiband = !settings.waveformMultiband;
break;
}
settings = normalizeSettings(settings);
return settings != before;
@@ -437,6 +642,16 @@ bool resetSetting(TuiSettings& settings, SettingId setting) {
case SettingId::VUNeedleChannels: settings.vuNeedleChannels = defaults.vuNeedleChannels; break;
case SettingId::VUReferenceLevel: settings.vuReferenceDbfs = defaults.vuReferenceDbfs; break;
case SettingId::LUFSReadout: settings.lufsReadout = defaults.lufsReadout; break;
case SettingId::SpectrogramColor: settings.spectrogramColor = defaults.spectrogramColor; break;
case SettingId::SpectrogramClarity: settings.spectrogramClarity = defaults.spectrogramClarity; break;
case SettingId::SpectrogramScale: settings.spectrogramScale = defaults.spectrogramScale; break;
case SettingId::SpectrogramOrientation: settings.spectrogramOrientation = defaults.spectrogramOrientation; break;
case SettingId::SpectrogramScrollSpeed: settings.spectrogramScrollSpeed = defaults.spectrogramScrollSpeed; break;
case SettingId::SpectrogramContrast: settings.spectrogramContrast = defaults.spectrogramContrast; break;
case SettingId::SpectrogramTilt: settings.spectrogramTiltDbPerOctave = defaults.spectrogramTiltDbPerOctave; break;
case SettingId::WaveformMode: settings.waveformMode = defaults.waveformMode; break;
case SettingId::WaveformScrollSpeed: settings.waveformScrollSpeed = defaults.waveformScrollSpeed; break;
case SettingId::WaveformMultiband: settings.waveformMultiband = defaults.waveformMultiband; break;
}
return settings != before;
}
@@ -487,6 +702,16 @@ TuiSettings loadSettings(const std::filesystem::path& path) {
else if (key == "vu_needle_channels") settings.vuNeedleChannels = parseVuNeedleChannels(value, settings.vuNeedleChannels);
else if (key == "vu_reference_dbfs") settings.vuReferenceDbfs = parseFloat(value, settings.vuReferenceDbfs);
else if (key == "lufs_readout") settings.lufsReadout = parseLufsReadout(value, settings.lufsReadout);
else if (key == "spectrogram_color") settings.spectrogramColor = parseSpectrogramColor(value, settings.spectrogramColor);
else if (key == "spectrogram_clarity") settings.spectrogramClarity = parseSpectrogramClarity(value, settings.spectrogramClarity);
else if (key == "spectrogram_scale") settings.spectrogramScale = parseSpectrogramScale(value, settings.spectrogramScale);
else if (key == "spectrogram_orientation") settings.spectrogramOrientation = parseSpectrogramOrientation(value, settings.spectrogramOrientation);
else if (key == "spectrogram_scroll") settings.spectrogramScrollSpeed = parseFloat(value, settings.spectrogramScrollSpeed);
else if (key == "spectrogram_contrast") settings.spectrogramContrast = parseFloat(value, settings.spectrogramContrast);
else if (key == "spectrogram_tilt") settings.spectrogramTiltDbPerOctave = parseFloat(value, settings.spectrogramTiltDbPerOctave);
else if (key == "waveform_mode") settings.waveformMode = parseWaveformMode(value, settings.waveformMode);
else if (key == "waveform_scroll") settings.waveformScrollSpeed = parseInt(value, settings.waveformScrollSpeed);
else if (key == "waveform_multiband") settings.waveformMultiband = parseBool(value, settings.waveformMultiband);
}
return normalizeSettings(settings);
}
@@ -523,7 +748,17 @@ bool saveSettings(const TuiSettings& rawSettings,
<< "vu_orientation=" << serializeVuOrientation(settings.vuMeterOrientation) << '\n'
<< "vu_needle_channels=" << serializeVuNeedleChannels(settings.vuNeedleChannels) << '\n'
<< "vu_reference_dbfs=" << settings.vuReferenceDbfs << '\n'
<< "lufs_readout=" << serializeLufsReadout(settings.lufsReadout) << '\n';
<< "lufs_readout=" << serializeLufsReadout(settings.lufsReadout) << '\n'
<< "spectrogram_color=" << serializeSpectrogramColor(settings.spectrogramColor) << '\n'
<< "spectrogram_clarity=" << serializeSpectrogramClarity(settings.spectrogramClarity) << '\n'
<< "spectrogram_scale=" << serializeSpectrogramScale(settings.spectrogramScale) << '\n'
<< "spectrogram_orientation=" << serializeSpectrogramOrientation(settings.spectrogramOrientation) << '\n'
<< "spectrogram_scroll=" << settings.spectrogramScrollSpeed << '\n'
<< "spectrogram_contrast=" << settings.spectrogramContrast << '\n'
<< "spectrogram_tilt=" << settings.spectrogramTiltDbPerOctave << '\n'
<< "waveform_mode=" << serializeWaveformMode(settings.waveformMode) << '\n'
<< "waveform_scroll=" << settings.waveformScrollSpeed << '\n'
<< "waveform_multiband=" << (settings.waveformMultiband ? "true" : "false") << '\n';
if (!output) {
if (error) *error = "could not write settings file";
return false;
+34
View File
@@ -18,6 +18,8 @@ enum class SettingsPage {
Vectorscope,
VUMeter,
LUFSMeter,
Spectrogram,
Waveform,
};
enum class SettingId {
@@ -37,6 +39,16 @@ enum class SettingId {
VUNeedleChannels,
VUReferenceLevel,
LUFSReadout,
SpectrogramColor,
SpectrogramClarity,
SpectrogramScale,
SpectrogramOrientation,
SpectrogramScrollSpeed,
SpectrogramContrast,
SpectrogramTilt,
WaveformMode,
WaveformScrollSpeed,
WaveformMultiband,
};
enum class VectorscopeDetail {
@@ -45,6 +57,12 @@ enum class VectorscopeDetail {
Maximum,
};
enum class SpectrogramColorMode { Heat, Mono };
enum class SpectrogramClarity { Classic, Sharp, Sharper };
enum class SpectrogramScale { Mel, Logarithmic, Linear };
enum class SpectrogramOrientation { Horizontal, Vertical };
enum class WaveformMode { Mono, Stereo };
struct TuiSettings {
float inputTrimDb = 0.0f;
int refreshRate = 60;
@@ -62,6 +80,16 @@ struct TuiSettings {
VUNeedleChannels vuNeedleChannels = VUNeedleChannels::Stereo;
float vuReferenceDbfs = -14.0f;
LUFSReadout lufsReadout = LUFSReadout::ShortTerm;
SpectrogramColorMode spectrogramColor = SpectrogramColorMode::Heat;
SpectrogramClarity spectrogramClarity = SpectrogramClarity::Sharper;
SpectrogramScale spectrogramScale = SpectrogramScale::Logarithmic;
SpectrogramOrientation spectrogramOrientation = SpectrogramOrientation::Horizontal;
float spectrogramScrollSpeed = 2.0f;
float spectrogramContrast = 1.0f;
float spectrogramTiltDbPerOctave = 4.0f;
WaveformMode waveformMode = WaveformMode::Mono;
int waveformScrollSpeed = 1;
bool waveformMultiband = false;
};
struct SettingDescriptor {
@@ -84,6 +112,12 @@ bool settingIsBoolean(SettingId setting);
bool adjustSetting(TuiSettings& settings, SettingId setting, int direction);
bool resetSetting(TuiSettings& settings, SettingId setting);
const char* spectrogramColorName(SpectrogramColorMode mode);
const char* spectrogramClarityName(SpectrogramClarity clarity);
const char* spectrogramScaleName(SpectrogramScale scale);
const char* spectrogramOrientationName(SpectrogramOrientation orientation);
const char* waveformModeName(WaveformMode mode);
std::filesystem::path defaultSettingsPath();
TuiSettings loadSettings(const std::filesystem::path& path);
bool saveSettings(const TuiSettings& settings,
+91 -2
View File
@@ -4,6 +4,7 @@
#include "display_model.h"
#include "meter_display_model.h"
#include "scope_plot_model.h"
#include "scrolling_history.h"
#include "snapshot_store.h"
#include "spectrum_peak_model.h"
#include "system_audio_capture.h"
@@ -209,13 +210,23 @@ void testProjectionAndLayout() {
Prism::Tui::PanelId::Oscilloscope,
"panel focus should cycle forward");
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) ==
Prism::Tui::PanelId::LUFSMeter,
Prism::Tui::PanelId::Waveform,
"panel focus should cycle backward");
const auto compactPanels = Prism::Tui::visiblePanelOrder(stacked);
require(compactPanels.size() == 3 &&
Prism::Tui::nextPanel(
Prism::Tui::PanelId::Spectrum, compactPanels) == Prism::Tui::PanelId::VUMeter,
"compact layout focus should skip hidden visual scopes");
const auto full = Prism::Tui::buildDashboardLayout(
140, 44, Prism::Tui::LayoutPreset::Automatic);
require(full.panels.size() == 7 &&
Prism::Tui::layoutContainsPanel(full, Prism::Tui::PanelId::Spectrogram) &&
Prism::Tui::layoutContainsPanel(full, Prism::Tui::PanelId::Waveform),
"large dashboards should compose all seven scopes at once");
require(std::all_of(full.panels.begin(), full.panels.end(), [](const auto& panel) {
return panel.width > 0 && panel.height > 0;
}), "all seven panes should remain bounded after responsive layout");
}
void testMeterDisplayModels() {
@@ -279,7 +290,7 @@ void testSettingsModelAndPersistence() {
"settings normalization should enforce public ranges");
const auto pages = Prism::Tui::settingsPages();
require(pages.size() == 6 &&
require(pages.size() == 8 &&
Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 3,
"settings should expose shallow category pages");
Prism::Tui::TuiSettings adjusted;
@@ -312,6 +323,18 @@ void testSettingsModelAndPersistence() {
adjusted, Prism::Tui::SettingId::LUFSReadout, 1) &&
adjusted.lufsReadout == Prism::Tui::LUFSReadout::Integrated,
"LUFS settings should select an independent loudness window");
require(Prism::Tui::adjustSetting(
adjusted, Prism::Tui::SettingId::SpectrogramClarity, -1) &&
adjusted.spectrogramClarity == Prism::Tui::SpectrogramClarity::Sharp,
"spectrogram settings should cycle through the native clarity modes");
require(Prism::Tui::adjustSetting(
adjusted, Prism::Tui::SettingId::WaveformMode, 1) &&
adjusted.waveformMode == Prism::Tui::WaveformMode::Stereo,
"waveform settings should expose independent mono and stereo modes");
require(Prism::Tui::adjustSetting(
adjusted, Prism::Tui::SettingId::WaveformMultiband, 1) &&
adjusted.waveformMultiband,
"waveform settings should expose the GUI multiband color mode");
require(Prism::Tui::resetSetting(
adjusted, Prism::Tui::SettingId::InputTrim) &&
adjusted.inputTrimDb == 0.0f,
@@ -480,6 +503,27 @@ void testPitchReadoutResponse() {
"free-running oscilloscope windows should advance with every audio chunk");
}
void testScrollingHistory() {
Prism::Tui::ScrollingHistory history(2, 3);
history.append(std::vector<float>{
1.0f, 10.0f,
2.0f, 20.0f,
3.0f, 30.0f,
4.0f, 40.0f,
});
const auto wrapped = history.snapshot();
require(wrapped.columnCount == 3 && wrapped.columnStride == 2,
"rolling histories should remain at their fixed column capacity");
require(wrapped.values == std::vector<float>({
2.0f, 20.0f,
3.0f, 30.0f,
4.0f, 40.0f,
}), "rolling history snapshots should publish oldest-to-newest columns");
history.reset();
require(history.snapshot().values.empty(),
"history reset should remove old visual data");
}
void testPipelineAndFakeCapture() {
FakeCapture capture;
Prism::Capture::StartResult started;
@@ -527,6 +571,35 @@ void testPipelineAndFakeCapture() {
frame.vectorscope.multibandPoints.end(),
[](float sample) { return std::isfinite(sample); }),
"vectorscope samples should remain finite");
require(frame.spectrogram.display.columnCount > 0 &&
frame.spectrogram.display.columnStride == Prism::Tui::kSpectrogramHistoryRows &&
frame.spectrogram.heat.columnCount == frame.spectrogram.display.columnCount,
"the pipeline should publish synchronized spectrogram intensity histories");
const size_t latestSpectrogramOffset =
(frame.spectrogram.display.columnCount - 1) *
frame.spectrogram.display.columnStride;
const auto spectrogramBegin =
frame.spectrogram.display.values.begin() +
static_cast<std::ptrdiff_t>(latestSpectrogramOffset);
const auto spectrogramEnd = spectrogramBegin +
static_cast<std::ptrdiff_t>(frame.spectrogram.display.columnStride);
const size_t dominantSpectrogramRow = static_cast<size_t>(std::distance(
spectrogramBegin, std::max_element(spectrogramBegin, spectrogramEnd)));
require(dominantSpectrogramRow > 42 && dominantSpectrogramRow < 68,
"a deterministic 1 kHz tone should occupy the expected logarithmic spectrogram band");
require(frame.waveform.history.columnCount > 0 &&
frame.waveform.history.columnStride == Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE &&
!frame.waveform.stereo,
"the pipeline should publish a bounded mono waveform history by default");
const size_t latestWaveformOffset =
(frame.waveform.history.columnCount - 1) * frame.waveform.history.columnStride;
const float* latestWaveform =
frame.waveform.history.values.data() + latestWaveformOffset;
require(latestWaveform[0] < -0.20f && latestWaveform[1] > 0.20f,
"waveform columns should retain the real minimum and maximum sample envelope");
require(std::all_of(latestWaveform + 2, latestWaveform + 5, [](float value) {
return std::isfinite(value) && value >= 0.0f;
}), "waveform columns should retain finite native multiband RMS values");
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,
@@ -548,12 +621,22 @@ void testPipelineAndFakeCapture() {
"input trim should affect the real loudness analyzer before processing");
Prism::Tui::AnalysisPipeline stereoPipeline(48000.0f);
stereoPipeline.setWaveformSettings(true, 2);
for (int index = 0; index < 20; ++index) {
stereoPipeline.process(stereoSineChunk(1000.0f, 0.5f, 0.125f, 2400, 48000.0f));
}
const auto stereo = stereoPipeline.snapshot();
require(stereo.vu.barLDb > stereo.vu.barRDb + 10.0f,
"stereo VU values should preserve independent channel levels");
require(stereo.waveform.stereo && stereo.waveform.history.columnCount > 0,
"stereo waveform mode should publish independent channel envelopes");
const size_t stereoWaveformOffset =
(stereo.waveform.history.columnCount - 1) *
stereo.waveform.history.columnStride;
const float* stereoWaveform =
stereo.waveform.history.values.data() + stereoWaveformOffset;
require(stereoWaveform[1] > stereoWaveform[6] * 3.0f,
"stereo waveform envelopes should preserve independent channel amplitudes");
pipeline.reset();
const auto reset = pipeline.snapshot();
require(reset.lufs.integratedLUFS <= -59.0f, "reset should clear integrated loudness");
@@ -563,6 +646,11 @@ void testPipelineAndFakeCapture() {
"reset should clear the fast pitch readout");
require(reset.vectorscope.pointCount == 0 && reset.vectorscope.multibandPoints.empty(),
"reset should clear vectorscope history");
require(reset.spectrogram.display.columnCount == 0 &&
reset.spectrogram.heat.columnCount == 0,
"reset should clear both spectrogram histories");
require(reset.waveform.history.columnCount == 0,
"reset should clear waveform history");
require(capture.stopped, "fake capture should stop cleanly");
}
@@ -593,6 +681,7 @@ int main() {
testSettingsModelAndPersistence();
testScopePlotModels();
testPitchReadoutResponse();
testScrollingHistory();
testPipelineAndFakeCapture();
testThreadSafeSnapshots();
std::cout << "Prism TUI tests passed\n";