mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-17 11:11:18 +02:00
prism-tui PoC
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
if(APPLE AND NOT CMAKE_OSX_DEPLOYMENT_TARGET)
|
||||
set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15" CACHE STRING "Minimum macOS version" FORCE)
|
||||
endif()
|
||||
|
||||
project(PrismTui VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
set(PRISM_VERSION "development" CACHE STRING "Version printed by prism-tui --version")
|
||||
set(PRISM_NATIVE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../native/src")
|
||||
|
||||
include(FetchContent)
|
||||
set(FTXUI_BUILD_DOCS OFF CACHE BOOL "" FORCE)
|
||||
set(FTXUI_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(FTXUI_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(FTXUI_BUILD_TESTS_FUZZER OFF CACHE BOOL "" FORCE)
|
||||
set(FTXUI_ENABLE_INSTALL OFF CACHE BOOL "" FORCE)
|
||||
FetchContent_Declare(ftxui
|
||||
GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI.git
|
||||
# Commit tagged v7.0.1; use the immutable revision for reproducible builds.
|
||||
GIT_TAG c100eab535db2283b78d30fcb6d082a1f84fb683
|
||||
GIT_SHALLOW TRUE)
|
||||
FetchContent_MakeAvailable(ftxui)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
add_library(prism_tui_analysis STATIC
|
||||
src/analysis_pipeline.cpp
|
||||
src/cli.cpp
|
||||
src/display_model.cpp
|
||||
${PRISM_NATIVE_DIR}/spectrum.cpp
|
||||
${PRISM_NATIVE_DIR}/vumeter.cpp
|
||||
${PRISM_NATIVE_DIR}/lufsmeter.cpp
|
||||
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
|
||||
target_include_directories(prism_tui_analysis PUBLIC
|
||||
src
|
||||
${PRISM_NATIVE_DIR})
|
||||
target_link_libraries(prism_tui_analysis PUBLIC Threads::Threads)
|
||||
|
||||
add_library(prism_system_capture STATIC)
|
||||
target_include_directories(prism_system_capture PUBLIC ${PRISM_NATIVE_DIR})
|
||||
target_compile_definitions(prism_system_capture PRIVATE PRISM_CAPTURE_CORE_ONLY=1)
|
||||
target_link_libraries(prism_system_capture PUBLIC Threads::Threads)
|
||||
|
||||
if(APPLE)
|
||||
enable_language(OBJCXX)
|
||||
target_sources(prism_system_capture PRIVATE ${PRISM_NATIVE_DIR}/macos_capture.mm)
|
||||
target_link_libraries(prism_system_capture PRIVATE
|
||||
"-framework Foundation"
|
||||
"-framework CoreAudio"
|
||||
"-framework AudioToolbox")
|
||||
elseif(WIN32)
|
||||
target_sources(prism_system_capture PRIVATE ${PRISM_NATIVE_DIR}/windows_capture.cpp)
|
||||
target_compile_definitions(prism_system_capture PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX)
|
||||
target_link_libraries(prism_system_capture PRIVATE ole32 avrt uuid)
|
||||
else()
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(PULSE REQUIRED IMPORTED_TARGET libpulse)
|
||||
target_sources(prism_system_capture PRIVATE ${PRISM_NATIVE_DIR}/linux_capture.cpp)
|
||||
target_link_libraries(prism_system_capture PRIVATE PkgConfig::PULSE)
|
||||
endif()
|
||||
|
||||
add_executable(prism-tui
|
||||
src/main.cpp
|
||||
src/tui_runtime.cpp)
|
||||
target_compile_definitions(prism-tui PRIVATE PRISM_VERSION="${PRISM_VERSION}")
|
||||
if(MSVC)
|
||||
target_compile_options(prism-tui PRIVATE /utf-8)
|
||||
target_compile_options(prism_tui_analysis PRIVATE /utf-8)
|
||||
endif()
|
||||
target_link_libraries(prism-tui PRIVATE
|
||||
prism_tui_analysis
|
||||
prism_system_capture
|
||||
ftxui::ftxui)
|
||||
|
||||
set_target_properties(prism-tui PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
|
||||
|
||||
include(CTest)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(prism-tui-tests test/tui_tests.cpp)
|
||||
if(MSVC)
|
||||
target_compile_options(prism-tui-tests PRIVATE /utf-8)
|
||||
endif()
|
||||
target_link_libraries(prism-tui-tests PRIVATE prism_tui_analysis)
|
||||
add_test(NAME prism-tui-tests COMMAND prism-tui-tests)
|
||||
add_test(
|
||||
NAME prism-tui-cli-exits
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DPRISM_TUI_EXECUTABLE=$<TARGET_FILE:prism-tui>
|
||||
-DEXPECTED_VERSION=${PRISM_VERSION}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/test/cli_exit_tests.cmake)
|
||||
endif()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#include "analysis_pipeline.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace Prism::Tui {
|
||||
|
||||
AnalysisPipeline::AnalysisPipeline(float sampleRate, size_t fftSize)
|
||||
: spectrum_(fftSize) {
|
||||
spectrum_.setSampleRate(sampleRate);
|
||||
spectrum_.setSmoothing(0.9f);
|
||||
vu_.setSampleRate(sampleRate);
|
||||
lufs_.setSampleRate(sampleRate);
|
||||
}
|
||||
|
||||
void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) {
|
||||
const size_t count = std::min(chunk.left.size(), chunk.right.size());
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
spectrum_.pushStereoSamples(chunk.left.data(), chunk.right.data(), count);
|
||||
vu_.pushSamples(chunk.left.data(), chunk.right.data(), count);
|
||||
lufs_.pushSamples(chunk.left.data(), chunk.right.data(), count);
|
||||
}
|
||||
|
||||
AnalysisFrame AnalysisPipeline::snapshot() {
|
||||
return {
|
||||
spectrum_.getChannelMaxMagnitudes(),
|
||||
vu_.getSnapshot(),
|
||||
lufs_.getSnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
void AnalysisPipeline::reset() {
|
||||
spectrum_.reset();
|
||||
vu_.reset();
|
||||
lufs_.reset();
|
||||
}
|
||||
|
||||
size_t drainCapture(Prism::Capture::SystemAudioCapture& capture,
|
||||
AnalysisPipeline& pipeline,
|
||||
bool& captureOverrun,
|
||||
size_t maxChunks) {
|
||||
auto drained = capture.drain(maxChunks);
|
||||
captureOverrun = captureOverrun || drained.overwriteCount > 0;
|
||||
for (const auto& chunk : drained.chunks) {
|
||||
pipeline.process(chunk);
|
||||
}
|
||||
return drained.chunks.size();
|
||||
}
|
||||
|
||||
} // namespace Prism::Tui
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include "lufsmeter.h"
|
||||
#include "spectrum.h"
|
||||
#include "system_audio_capture.h"
|
||||
#include "vumeter.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace Prism::Tui {
|
||||
|
||||
struct AnalysisFrame {
|
||||
std::vector<float> magnitudes;
|
||||
Visualizer::VUMeterSnapshot vu{};
|
||||
Visualizer::LUFSMeterSnapshot lufs{};
|
||||
};
|
||||
|
||||
class AnalysisPipeline {
|
||||
public:
|
||||
explicit AnalysisPipeline(float sampleRate, size_t fftSize = 2048);
|
||||
|
||||
void process(const Prism::Capture::AudioChunk& chunk);
|
||||
AnalysisFrame snapshot();
|
||||
void reset();
|
||||
|
||||
private:
|
||||
Visualizer::Spectrum spectrum_;
|
||||
Visualizer::VUMeterAnalyzer vu_;
|
||||
Visualizer::LUFSMeterAnalyzer lufs_;
|
||||
};
|
||||
|
||||
size_t drainCapture(Prism::Capture::SystemAudioCapture& capture,
|
||||
AnalysisPipeline& pipeline,
|
||||
bool& captureOverrun,
|
||||
size_t maxChunks = 64);
|
||||
|
||||
} // namespace Prism::Tui
|
||||
@@ -0,0 +1,62 @@
|
||||
#include "cli.h"
|
||||
|
||||
namespace Prism::Tui {
|
||||
|
||||
ParseResult parseArguments(const std::vector<std::string>& arguments) {
|
||||
ParseResult result;
|
||||
result.ok = true;
|
||||
|
||||
for (size_t index = 0; index < arguments.size(); ++index) {
|
||||
const auto& argument = arguments[index];
|
||||
if (argument == "--help" || argument == "-h") {
|
||||
if (arguments.size() != 1) {
|
||||
return {false, {}, "--help cannot be combined with other arguments."};
|
||||
}
|
||||
result.options.command = Command::Help;
|
||||
continue;
|
||||
}
|
||||
if (argument == "--version" || argument == "-V") {
|
||||
if (arguments.size() != 1) {
|
||||
return {false, {}, "--version cannot be combined with other arguments."};
|
||||
}
|
||||
result.options.command = Command::Version;
|
||||
continue;
|
||||
}
|
||||
if (argument == "--list-devices") {
|
||||
if (arguments.size() != 1) {
|
||||
return {false, {}, "--list-devices cannot be combined with other arguments."};
|
||||
}
|
||||
result.options.command = Command::ListDevices;
|
||||
continue;
|
||||
}
|
||||
if (argument == "--device") {
|
||||
if (index + 1 >= arguments.size() || arguments[index + 1].empty() ||
|
||||
arguments[index + 1][0] == '-') {
|
||||
return {false, {}, "--device requires a non-empty device ID."};
|
||||
}
|
||||
if (!result.options.deviceId.empty()) {
|
||||
return {false, {}, "--device may only be specified once."};
|
||||
}
|
||||
result.options.deviceId = arguments[++index];
|
||||
continue;
|
||||
}
|
||||
return {false, {}, "Unknown argument: " + argument};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string usageText() {
|
||||
return
|
||||
"Usage: prism-tui [--device <id>]\n"
|
||||
" prism-tui --list-devices\n"
|
||||
" prism-tui --help\n"
|
||||
" prism-tui --version\n\n"
|
||||
"Options:\n"
|
||||
" --device <id> Capture a specific system output device.\n"
|
||||
" --list-devices List available system output devices.\n"
|
||||
" -h, --help Show this help.\n"
|
||||
" -V, --version Show the Prism TUI version.\n";
|
||||
}
|
||||
|
||||
} // namespace Prism::Tui
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Prism::Tui {
|
||||
|
||||
enum class Command {
|
||||
Run,
|
||||
ListDevices,
|
||||
Help,
|
||||
Version,
|
||||
};
|
||||
|
||||
struct Options {
|
||||
Command command = Command::Run;
|
||||
std::string deviceId;
|
||||
};
|
||||
|
||||
struct ParseResult {
|
||||
bool ok = false;
|
||||
Options options;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
ParseResult parseArguments(const std::vector<std::string>& arguments);
|
||||
std::string usageText();
|
||||
|
||||
} // namespace Prism::Tui
|
||||
@@ -0,0 +1,166 @@
|
||||
#include "display_model.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iomanip>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
|
||||
namespace Prism::Tui {
|
||||
namespace {
|
||||
|
||||
float frequencyAt(float position, float minFrequency, float maxFrequency) {
|
||||
const float logMin = std::log10(minFrequency);
|
||||
const float logMax = std::log10(maxFrequency);
|
||||
return std::pow(10.0f, logMin + position * (logMax - logMin));
|
||||
}
|
||||
|
||||
void placeLabel(std::string& axis, size_t position, const std::string& label) {
|
||||
if (axis.empty() || label.size() > axis.size()) {
|
||||
return;
|
||||
}
|
||||
const size_t start = std::min(
|
||||
axis.size() - label.size(),
|
||||
position > label.size() / 2 ? position - label.size() / 2 : size_t{0});
|
||||
for (size_t index = 0; index < label.size(); ++index) {
|
||||
axis[start + index] = label[index];
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LayoutModel calculateLayout(int width, int height) {
|
||||
LayoutModel model;
|
||||
model.terminalTooSmall = width < 44 || height < 12;
|
||||
if (model.terminalTooSmall) {
|
||||
return model;
|
||||
}
|
||||
model.contentWidth = static_cast<size_t>(std::max(8, width - 4));
|
||||
model.spectrumRowCount = static_cast<size_t>(std::max(2, height - 10));
|
||||
model.meterWidth = static_cast<size_t>(std::max(8, width - 19));
|
||||
return model;
|
||||
}
|
||||
|
||||
std::vector<float> projectSpectrum(const std::vector<float>& magnitudes,
|
||||
size_t fftSize,
|
||||
size_t columns,
|
||||
const SpectrumProjectionOptions& options) {
|
||||
if (magnitudes.empty() || fftSize == 0 || columns == 0 || options.sampleRate <= 0.0f) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const float nyquist = options.sampleRate * 0.5f;
|
||||
const float minFrequency = std::max(1.0f, std::min(options.minFrequency, nyquist));
|
||||
const float maxFrequency = std::max(
|
||||
minFrequency,
|
||||
std::min(options.maxFrequency, nyquist));
|
||||
const float binWidth = options.sampleRate / static_cast<float>(fftSize);
|
||||
const float dbSpan = std::max(1.0f, options.maxDecibels - options.minDecibels);
|
||||
|
||||
std::vector<float> projected(columns, 0.0f);
|
||||
for (size_t column = 0; column < columns; ++column) {
|
||||
const float leftPosition = static_cast<float>(column) / static_cast<float>(columns);
|
||||
const float rightPosition = static_cast<float>(column + 1) / static_cast<float>(columns);
|
||||
const float leftFrequency = frequencyAt(leftPosition, minFrequency, maxFrequency);
|
||||
const float rightFrequency = frequencyAt(rightPosition, minFrequency, maxFrequency);
|
||||
const size_t firstBin = std::min(
|
||||
magnitudes.size() - 1,
|
||||
static_cast<size_t>(std::floor(leftFrequency / binWidth)));
|
||||
const size_t lastBin = std::min(
|
||||
magnitudes.size() - 1,
|
||||
std::max(firstBin, static_cast<size_t>(std::ceil(rightFrequency / binWidth))));
|
||||
|
||||
float peakDb = -120.0f;
|
||||
for (size_t bin = firstBin; bin <= lastBin; ++bin) {
|
||||
const float value = std::isfinite(magnitudes[bin]) ? magnitudes[bin] : -120.0f;
|
||||
peakDb = std::max(peakDb, value);
|
||||
}
|
||||
|
||||
const float centerFrequency = std::sqrt(leftFrequency * rightFrequency);
|
||||
const float tilt = options.tiltDbPerOctave *
|
||||
std::log2(std::max(1.0f, centerFrequency) / std::max(1.0f, options.tiltReferenceHz));
|
||||
projected[column] = std::clamp(
|
||||
(peakDb + tilt - options.minDecibels) / dbSpan,
|
||||
0.0f,
|
||||
1.0f);
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
std::vector<std::string> buildSpectrumRows(const std::vector<float>& normalized,
|
||||
size_t rowCount) {
|
||||
if (normalized.empty() || rowCount == 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
static const char* partialBlocks[] = {" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇"};
|
||||
std::vector<std::string> rows(rowCount);
|
||||
const int totalUnits = static_cast<int>(rowCount * 8);
|
||||
for (size_t row = 0; row < rowCount; ++row) {
|
||||
std::string line;
|
||||
const int rowBottom = static_cast<int>((rowCount - row - 1) * 8);
|
||||
for (float value : normalized) {
|
||||
const int filled = static_cast<int>(std::round(std::clamp(value, 0.0f, 1.0f) * totalUnits));
|
||||
const int units = std::clamp(filled - rowBottom, 0, 8);
|
||||
line += units == 8 ? "█" : partialBlocks[units];
|
||||
}
|
||||
rows[row] = std::move(line);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
std::string buildFrequencyAxis(size_t columns, float maxFrequency) {
|
||||
std::string axis(columns, ' ');
|
||||
if (columns < 8) {
|
||||
return axis;
|
||||
}
|
||||
const float resolvedMax = std::max(20.0f, maxFrequency);
|
||||
const auto positionFor = [&](float frequency) {
|
||||
const float position = std::log10(frequency / 20.0f) / std::log10(resolvedMax / 20.0f);
|
||||
return static_cast<size_t>(std::round(std::clamp(position, 0.0f, 1.0f) * (columns - 1)));
|
||||
};
|
||||
placeLabel(axis, 0, "20");
|
||||
if (resolvedMax >= 100.0f) placeLabel(axis, positionFor(100.0f), "100");
|
||||
if (resolvedMax >= 1000.0f) placeLabel(axis, positionFor(1000.0f), "1k");
|
||||
if (resolvedMax >= 10000.0f) placeLabel(axis, positionFor(10000.0f), "10k");
|
||||
placeLabel(axis, columns - 1, resolvedMax >= 19950.0f ? "20k" : "Nyq");
|
||||
return axis;
|
||||
}
|
||||
|
||||
std::string buildMeterBar(float levelDb, float peakDb, size_t columns) {
|
||||
if (columns == 0) {
|
||||
return {};
|
||||
}
|
||||
const auto toPosition = [&](float db) {
|
||||
const float normalized = std::clamp((db + 60.0f) / 60.0f, 0.0f, 1.0f);
|
||||
return static_cast<size_t>(std::round(normalized * static_cast<float>(columns)));
|
||||
};
|
||||
const size_t level = std::min(columns, toPosition(levelDb));
|
||||
const size_t peak = std::min(columns - 1, toPosition(peakDb));
|
||||
std::string result;
|
||||
for (size_t column = 0; column < columns; ++column) {
|
||||
if (column == peak && peak > level) {
|
||||
result += "│";
|
||||
} else if (column < level) {
|
||||
result += "█";
|
||||
} else {
|
||||
result += "·";
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string formatDb(float value, int precision) {
|
||||
if (!std::isfinite(value) || value <= -60.0f) {
|
||||
return "-inf";
|
||||
}
|
||||
std::ostringstream output;
|
||||
output << std::fixed << std::setprecision(precision) << value;
|
||||
return output.str();
|
||||
}
|
||||
|
||||
std::string formatLufs(float value) {
|
||||
return formatDb(value, 1);
|
||||
}
|
||||
|
||||
} // namespace Prism::Tui
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Prism::Tui {
|
||||
|
||||
struct SpectrumProjectionOptions {
|
||||
float sampleRate = 48000.0f;
|
||||
float minFrequency = 20.0f;
|
||||
float maxFrequency = 20000.0f;
|
||||
float minDecibels = -90.0f;
|
||||
float maxDecibels = -10.0f;
|
||||
float tiltDbPerOctave = 2.0f;
|
||||
float tiltReferenceHz = 1000.0f;
|
||||
};
|
||||
|
||||
struct LayoutModel {
|
||||
bool terminalTooSmall = true;
|
||||
size_t contentWidth = 0;
|
||||
size_t spectrumRowCount = 0;
|
||||
size_t meterWidth = 0;
|
||||
};
|
||||
|
||||
LayoutModel calculateLayout(int width, int height);
|
||||
|
||||
std::vector<float> projectSpectrum(const std::vector<float>& magnitudes,
|
||||
size_t fftSize,
|
||||
size_t columns,
|
||||
const SpectrumProjectionOptions& options);
|
||||
|
||||
std::vector<std::string> buildSpectrumRows(const std::vector<float>& normalized,
|
||||
size_t rowCount);
|
||||
std::string buildFrequencyAxis(size_t columns, float maxFrequency);
|
||||
std::string buildMeterBar(float levelDb, float peakDb, size_t columns);
|
||||
std::string formatDb(float value, int precision = 1);
|
||||
std::string formatLufs(float value);
|
||||
|
||||
} // namespace Prism::Tui
|
||||
@@ -0,0 +1,89 @@
|
||||
#include "cli.h"
|
||||
#include "system_audio_capture.h"
|
||||
#include "tui_runtime.h"
|
||||
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifndef PRISM_VERSION
|
||||
#define PRISM_VERSION "development"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
int run(const std::vector<std::string>& arguments) {
|
||||
const auto parsed = Prism::Tui::parseArguments(arguments);
|
||||
if (!parsed.ok) {
|
||||
std::cerr << "prism-tui: " << parsed.error << "\n\n" << Prism::Tui::usageText();
|
||||
return 2;
|
||||
}
|
||||
if (parsed.options.command == Prism::Tui::Command::Help) {
|
||||
std::cout << Prism::Tui::usageText();
|
||||
return 0;
|
||||
}
|
||||
if (parsed.options.command == Prism::Tui::Command::Version) {
|
||||
std::cout << "prism-tui " << PRISM_VERSION << '\n';
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto capture = Prism::Capture::createSystemAudioCapture();
|
||||
const auto support = capture->getSupport();
|
||||
if (!support.available) {
|
||||
std::cerr << "prism-tui: " << support.reason << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (parsed.options.command == Prism::Tui::Command::ListDevices) {
|
||||
const auto devices = capture->listOutputDevices();
|
||||
if (devices.empty()) {
|
||||
std::cerr << "prism-tui: no system output devices found.\n";
|
||||
return 1;
|
||||
}
|
||||
for (const auto& device : devices) {
|
||||
std::cout << device.id << '\t' << device.label;
|
||||
if (device.isDefault) {
|
||||
std::cout << "\t(default)";
|
||||
}
|
||||
std::cout << '\t' << static_cast<int>(device.sampleRate) << " Hz"
|
||||
<< '\t' << device.channelCount << " ch\n";
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!Prism::Tui::stdinAndStdoutAreTerminals()) {
|
||||
std::cerr << "prism-tui: interactive mode requires a terminal on stdin and stdout.\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
Prism::Capture::StartResult started;
|
||||
std::string errorMessage;
|
||||
if (!capture->start(parsed.options.deviceId, &started, &errorMessage)) {
|
||||
std::cerr << "prism-tui: "
|
||||
<< (errorMessage.empty() ? "System audio capture failed to start." : errorMessage)
|
||||
<< '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Prism::Tui::runInteractive(std::move(capture), started);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
std::vector<std::string> arguments;
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
arguments.emplace_back(argv[index]);
|
||||
}
|
||||
return run(arguments);
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "prism-tui: " << error.what() << '\n';
|
||||
return 1;
|
||||
} catch (...) {
|
||||
std::cerr << "prism-tui: unexpected runtime failure.\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
|
||||
namespace Prism::Tui {
|
||||
|
||||
template <typename T>
|
||||
class SnapshotStore {
|
||||
public:
|
||||
void publish(T next) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
snapshot_ = std::move(next);
|
||||
}
|
||||
|
||||
T read() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return snapshot_;
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
T snapshot_{};
|
||||
};
|
||||
|
||||
} // namespace Prism::Tui
|
||||
@@ -0,0 +1,252 @@
|
||||
#include "tui_runtime.h"
|
||||
|
||||
#include "analysis_pipeline.h"
|
||||
#include "display_model.h"
|
||||
#include "snapshot_store.h"
|
||||
|
||||
#include <ftxui/component/component.hpp>
|
||||
#include <ftxui/component/event.hpp>
|
||||
#include <ftxui/component/screen_interactive.hpp>
|
||||
#include <ftxui/dom/elements.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <exception>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#include <io.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace Prism::Tui {
|
||||
namespace {
|
||||
|
||||
constexpr size_t kFftSize = 2048;
|
||||
constexpr auto kCapturePollInterval = std::chrono::milliseconds(2);
|
||||
constexpr auto kDisplayFrameInterval = std::chrono::milliseconds(33);
|
||||
|
||||
volatile std::sig_atomic_t signalRequested = 0;
|
||||
|
||||
void handleSignal(int) {
|
||||
signalRequested = 1;
|
||||
}
|
||||
|
||||
class SignalHandlerGuard {
|
||||
public:
|
||||
SignalHandlerGuard()
|
||||
: previousSigInt_(std::signal(SIGINT, handleSignal)),
|
||||
previousSigTerm_(std::signal(SIGTERM, handleSignal)) {}
|
||||
|
||||
~SignalHandlerGuard() {
|
||||
if (previousSigInt_ != SIG_ERR) std::signal(SIGINT, previousSigInt_);
|
||||
if (previousSigTerm_ != SIG_ERR) std::signal(SIGTERM, previousSigTerm_);
|
||||
}
|
||||
|
||||
private:
|
||||
using Handler = void (*)(int);
|
||||
Handler previousSigInt_;
|
||||
Handler previousSigTerm_;
|
||||
};
|
||||
|
||||
struct DisplayFrame {
|
||||
std::vector<float> magnitudes;
|
||||
Visualizer::VUMeterSnapshot vu{};
|
||||
Visualizer::LUFSMeterSnapshot lufs{};
|
||||
double sampleRate = 48000.0;
|
||||
std::string backend;
|
||||
std::string device;
|
||||
bool captureOverrun = false;
|
||||
};
|
||||
|
||||
std::string makeFooter(const DisplayFrame& frame) {
|
||||
std::ostringstream sampleRate;
|
||||
const double kilohertz = frame.sampleRate / 1000.0;
|
||||
sampleRate << std::fixed << std::setprecision(
|
||||
std::abs(kilohertz - std::round(kilohertz)) < 0.01 ? 0 : 1) << kilohertz;
|
||||
std::string footer = frame.backend + " • " + frame.device + " • " +
|
||||
sampleRate.str() + " kHz";
|
||||
if (frame.captureOverrun) {
|
||||
footer += " • capture overrun";
|
||||
}
|
||||
footer += " r reset • q/Esc/Ctrl-C quit";
|
||||
return footer;
|
||||
}
|
||||
|
||||
ftxui::Element renderFrame(const DisplayFrame& frame, int width, int height) {
|
||||
using namespace ftxui;
|
||||
const auto layout = calculateLayout(width, height);
|
||||
if (layout.terminalTooSmall) {
|
||||
return vbox({
|
||||
filler(),
|
||||
text("Prism TUI") | bold | center,
|
||||
text("Terminal too small — need at least 44 × 12") | center,
|
||||
text("q quit") | dim | center,
|
||||
filler(),
|
||||
});
|
||||
}
|
||||
|
||||
SpectrumProjectionOptions projectionOptions;
|
||||
projectionOptions.sampleRate = static_cast<float>(frame.sampleRate);
|
||||
projectionOptions.maxFrequency = std::min(20000.0f, projectionOptions.sampleRate * 0.5f);
|
||||
const auto projected = projectSpectrum(
|
||||
frame.magnitudes,
|
||||
kFftSize,
|
||||
layout.contentWidth,
|
||||
projectionOptions);
|
||||
const auto spectrumRows = buildSpectrumRows(projected, layout.spectrumRowCount);
|
||||
|
||||
Elements spectrumElements;
|
||||
spectrumElements.reserve(spectrumRows.size() + 1);
|
||||
for (const auto& row : spectrumRows) {
|
||||
spectrumElements.push_back(text(row) | color(Color::Cyan));
|
||||
}
|
||||
spectrumElements.push_back(
|
||||
text(buildFrequencyAxis(layout.contentWidth, projectionOptions.maxFrequency)) | dim);
|
||||
|
||||
const auto meterRow = [&](const char* label, float level, float peak) {
|
||||
return hbox({
|
||||
text(std::string(label) + " ") | bold,
|
||||
text(buildMeterBar(level, peak, layout.meterWidth)) | color(Color::Cyan),
|
||||
text(" " + formatDb(level) + " dB"),
|
||||
});
|
||||
};
|
||||
|
||||
const std::string lufs =
|
||||
"LUFS M " + formatLufs(frame.lufs.momentaryLUFS) +
|
||||
" S " + formatLufs(frame.lufs.shortTermLUFS) +
|
||||
" I " + formatLufs(frame.lufs.integratedLUFS);
|
||||
|
||||
return vbox({
|
||||
text("PRISM TUI") | bold | center,
|
||||
window(text(" Spectrum ") | bold, vbox(std::move(spectrumElements))) | flex,
|
||||
meterRow("L", frame.vu.barLDb, frame.vu.peakLDb),
|
||||
meterRow("R", frame.vu.barRDb, frame.vu.peakRDb),
|
||||
text(lufs) | color(Color::Yellow),
|
||||
separator(),
|
||||
text(makeFooter(frame)) | dim,
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool stdinAndStdoutAreTerminals() {
|
||||
#if defined(_WIN32)
|
||||
return _isatty(_fileno(stdin)) != 0 && _isatty(_fileno(stdout)) != 0;
|
||||
#else
|
||||
return isatty(fileno(stdin)) != 0 && isatty(fileno(stdout)) != 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
||||
const Prism::Capture::StartResult& started) {
|
||||
using namespace ftxui;
|
||||
signalRequested = 0;
|
||||
SignalHandlerGuard signalHandlerGuard;
|
||||
|
||||
ScreenInteractive screen = ScreenInteractive::Fullscreen();
|
||||
SnapshotStore<DisplayFrame> frameStore;
|
||||
DisplayFrame initial;
|
||||
initial.magnitudes.assign(kFftSize / 2, -100.0f);
|
||||
initial.sampleRate = started.sampleRate;
|
||||
initial.backend = capture->backendName();
|
||||
initial.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel;
|
||||
frameStore.publish(initial);
|
||||
|
||||
std::atomic<bool> running{true};
|
||||
std::atomic<bool> resetRequested{false};
|
||||
std::exception_ptr workerError;
|
||||
auto exitLoop = screen.ExitLoopClosure();
|
||||
|
||||
std::thread worker([&]() {
|
||||
try {
|
||||
AnalysisPipeline pipeline(static_cast<float>(started.sampleRate), kFftSize);
|
||||
|
||||
bool captureOverrun = false;
|
||||
auto nextFrameAt = std::chrono::steady_clock::now();
|
||||
while (running.load()) {
|
||||
if (signalRequested != 0) {
|
||||
running.store(false);
|
||||
exitLoop();
|
||||
break;
|
||||
}
|
||||
if (resetRequested.exchange(false)) {
|
||||
pipeline.reset();
|
||||
captureOverrun = false;
|
||||
}
|
||||
|
||||
drainCapture(*capture, pipeline, captureOverrun);
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now >= nextFrameAt) {
|
||||
DisplayFrame next;
|
||||
auto analyzed = pipeline.snapshot();
|
||||
next.magnitudes = std::move(analyzed.magnitudes);
|
||||
next.vu = analyzed.vu;
|
||||
next.lufs = analyzed.lufs;
|
||||
next.sampleRate = started.sampleRate;
|
||||
next.backend = capture->backendName();
|
||||
next.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel;
|
||||
next.captureOverrun = captureOverrun;
|
||||
frameStore.publish(std::move(next));
|
||||
screen.PostEvent(Event::Custom);
|
||||
nextFrameAt = now + kDisplayFrameInterval;
|
||||
}
|
||||
std::this_thread::sleep_for(kCapturePollInterval);
|
||||
}
|
||||
} catch (...) {
|
||||
workerError = std::current_exception();
|
||||
if (running.exchange(false)) {
|
||||
exitLoop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
auto renderer = Renderer([&]() {
|
||||
return renderFrame(frameStore.read(), screen.dimx(), screen.dimy());
|
||||
});
|
||||
auto component = CatchEvent(renderer, [&](Event event) {
|
||||
if (event == Event::Character('q') || event == Event::Escape || event == Event::CtrlC) {
|
||||
running.store(false);
|
||||
exitLoop();
|
||||
return true;
|
||||
}
|
||||
if (event == Event::Character('r')) {
|
||||
resetRequested.store(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
std::exception_ptr screenError;
|
||||
try {
|
||||
screen.Loop(component);
|
||||
} catch (...) {
|
||||
screenError = std::current_exception();
|
||||
}
|
||||
running.store(false);
|
||||
if (worker.joinable()) {
|
||||
worker.join();
|
||||
}
|
||||
capture->stop();
|
||||
|
||||
if (workerError) {
|
||||
std::rethrow_exception(workerError);
|
||||
}
|
||||
if (screenError) {
|
||||
std::rethrow_exception(screenError);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace Prism::Tui
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include "system_audio_capture.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace Prism::Tui {
|
||||
|
||||
bool stdinAndStdoutAreTerminals();
|
||||
int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
|
||||
const Prism::Capture::StartResult& started);
|
||||
|
||||
} // namespace Prism::Tui
|
||||
@@ -0,0 +1,37 @@
|
||||
execute_process(
|
||||
COMMAND "${PRISM_TUI_EXECUTABLE}" --help
|
||||
RESULT_VARIABLE help_result
|
||||
OUTPUT_VARIABLE help_output
|
||||
ERROR_VARIABLE help_error)
|
||||
if(NOT help_result EQUAL 0 OR NOT help_output MATCHES "Usage: prism-tui")
|
||||
message(FATAL_ERROR "--help failed (${help_result}): ${help_output}${help_error}")
|
||||
endif()
|
||||
|
||||
execute_process(
|
||||
COMMAND "${PRISM_TUI_EXECUTABLE}" --version
|
||||
RESULT_VARIABLE version_result
|
||||
OUTPUT_VARIABLE version_output
|
||||
ERROR_VARIABLE version_error)
|
||||
if(NOT version_result EQUAL 0 OR NOT version_output MATCHES "prism-tui ${EXPECTED_VERSION}")
|
||||
message(FATAL_ERROR "--version failed (${version_result}): ${version_output}${version_error}")
|
||||
endif()
|
||||
|
||||
execute_process(
|
||||
COMMAND "${PRISM_TUI_EXECUTABLE}" --definitely-invalid
|
||||
RESULT_VARIABLE invalid_result
|
||||
OUTPUT_VARIABLE invalid_output
|
||||
ERROR_VARIABLE invalid_error)
|
||||
if(NOT invalid_result EQUAL 2 OR NOT invalid_error MATCHES "Unknown argument")
|
||||
message(FATAL_ERROR "invalid CLI exit was ${invalid_result}: ${invalid_output}${invalid_error}")
|
||||
endif()
|
||||
|
||||
execute_process(
|
||||
COMMAND "${PRISM_TUI_EXECUTABLE}"
|
||||
RESULT_VARIABLE noninteractive_result
|
||||
OUTPUT_VARIABLE noninteractive_output
|
||||
ERROR_VARIABLE noninteractive_error)
|
||||
if(NOT noninteractive_result EQUAL 1)
|
||||
message(FATAL_ERROR
|
||||
"noninteractive CLI exit was ${noninteractive_result}: "
|
||||
"${noninteractive_output}${noninteractive_error}")
|
||||
endif()
|
||||
@@ -0,0 +1,223 @@
|
||||
#include "analysis_pipeline.h"
|
||||
#include "cli.h"
|
||||
#include "display_model.h"
|
||||
#include "snapshot_store.h"
|
||||
#include "system_audio_capture.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <deque>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << "FAIL: " << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Prism::Capture::AudioChunk sineChunk(float frequency,
|
||||
float amplitude,
|
||||
size_t count,
|
||||
float sampleRate) {
|
||||
Prism::Capture::AudioChunk chunk;
|
||||
chunk.left.resize(count);
|
||||
chunk.right.resize(count);
|
||||
chunk.channelCount = 2;
|
||||
constexpr float pi = 3.14159265358979323846f;
|
||||
for (size_t index = 0; index < count; ++index) {
|
||||
const float sample = amplitude * std::sin(
|
||||
2.0f * pi * frequency * static_cast<float>(index) / sampleRate);
|
||||
chunk.left[index] = sample;
|
||||
chunk.right[index] = sample;
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
Prism::Capture::AudioChunk stereoSineChunk(float frequency,
|
||||
float leftAmplitude,
|
||||
float rightAmplitude,
|
||||
size_t count,
|
||||
float sampleRate) {
|
||||
auto chunk = sineChunk(frequency, leftAmplitude, count, sampleRate);
|
||||
constexpr float pi = 3.14159265358979323846f;
|
||||
for (size_t index = 0; index < count; ++index) {
|
||||
chunk.right[index] = rightAmplitude * std::sin(
|
||||
2.0f * pi * frequency * static_cast<float>(index) / sampleRate);
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
class FakeCapture final : public Prism::Capture::SystemAudioCapture {
|
||||
public:
|
||||
Prism::Capture::Support getSupport() const override { return {true, {}}; }
|
||||
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
|
||||
return {{"fake", "Fake Output", 48000.0, 2, true}};
|
||||
}
|
||||
bool start(const std::string& requested,
|
||||
Prism::Capture::StartResult* result,
|
||||
std::string*) override {
|
||||
if (!requested.empty() && requested != "fake") return false;
|
||||
if (result) *result = {48000.0, 2, "fake", "Fake Output"};
|
||||
return true;
|
||||
}
|
||||
void stop() override { stopped = true; }
|
||||
Prism::Capture::DrainResult drain(size_t maxChunks) override {
|
||||
Prism::Capture::DrainResult result;
|
||||
const size_t count = std::min(maxChunks, chunks.size());
|
||||
for (size_t index = 0; index < count; ++index) {
|
||||
result.chunks.push_back(std::move(chunks.front()));
|
||||
chunks.pop_front();
|
||||
}
|
||||
result.overwriteCount = nextOverwriteCount;
|
||||
nextOverwriteCount = 0;
|
||||
result.queueDepth = chunks.size();
|
||||
return result;
|
||||
}
|
||||
double nowMilliseconds() const override { return 1.0; }
|
||||
const char* backendName() const override { return "Fake"; }
|
||||
|
||||
std::deque<Prism::Capture::AudioChunk> chunks;
|
||||
uint64_t nextOverwriteCount = 0;
|
||||
bool stopped = false;
|
||||
};
|
||||
|
||||
void testCli() {
|
||||
auto parsed = Prism::Tui::parseArguments({"--device", "device-id"});
|
||||
require(parsed.ok, "device arguments should parse");
|
||||
require(parsed.options.command == Prism::Tui::Command::Run, "device command should run");
|
||||
require(parsed.options.deviceId == "device-id", "device ID should be retained");
|
||||
require(Prism::Tui::parseArguments({"--list-devices"}).options.command ==
|
||||
Prism::Tui::Command::ListDevices, "list command should parse");
|
||||
require(!Prism::Tui::parseArguments({"--device"}).ok, "missing device ID should fail");
|
||||
require(!Prism::Tui::parseArguments({"--device", "--help"}).ok,
|
||||
"an option should not be accepted as a device ID");
|
||||
require(!Prism::Tui::parseArguments({"--wat"}).ok, "unknown option should fail");
|
||||
require(!Prism::Tui::parseArguments({"--device", "fake", "--device", "fake"}).ok,
|
||||
"duplicate device options should fail");
|
||||
require(!Prism::Tui::parseArguments({"--help", "--version"}).ok,
|
||||
"exclusive commands should not combine");
|
||||
}
|
||||
|
||||
void testProjectionAndLayout() {
|
||||
constexpr float sampleRate = 48000.0f;
|
||||
constexpr size_t fftSize = 2048;
|
||||
const float binFrequency = 43.0f * sampleRate / static_cast<float>(fftSize);
|
||||
Prism::Tui::AnalysisPipeline pipeline(sampleRate, fftSize);
|
||||
for (int index = 0; index < 20; ++index) {
|
||||
pipeline.process(sineChunk(binFrequency, 0.5f, fftSize, sampleRate));
|
||||
}
|
||||
const auto frame = pipeline.snapshot();
|
||||
const auto projected = Prism::Tui::projectSpectrum(
|
||||
frame.magnitudes, fftSize, 120, {sampleRate});
|
||||
require(projected.size() == 120, "projection should match terminal width");
|
||||
require(std::all_of(projected.begin(), projected.end(), [](float value) {
|
||||
return std::isfinite(value) && value >= 0.0f && value <= 1.0f;
|
||||
}), "projected values should be finite and normalized");
|
||||
const auto peak = static_cast<size_t>(std::distance(
|
||||
projected.begin(), std::max_element(projected.begin(), projected.end())));
|
||||
if (!(peak > 55 && peak < 75)) {
|
||||
std::cerr << "Projected 1 kHz peak column: " << peak << '\n';
|
||||
}
|
||||
require(peak > 55 && peak < 75, "1 kHz peak should land in the logarithmic center region");
|
||||
require(Prism::Tui::buildSpectrumRows(projected, 6).size() == 6,
|
||||
"spectrum rows should follow the requested height");
|
||||
require(Prism::Tui::buildSpectrumRows(projected, 0).empty(),
|
||||
"zero-height spectrum should be empty");
|
||||
const auto meter = Prism::Tui::buildMeterBar(-12.0f, -6.0f, 20);
|
||||
require(!meter.empty(),
|
||||
"meter bar should render");
|
||||
require(meter.find("│") != std::string::npos,
|
||||
"meter bar should include its peak marker");
|
||||
|
||||
const auto normal = Prism::Tui::calculateLayout(100, 30);
|
||||
const auto narrow = Prism::Tui::calculateLayout(44, 12);
|
||||
require(!normal.terminalTooSmall && normal.spectrumRowCount == 20,
|
||||
"normal terminal layout should fill available height");
|
||||
require(!narrow.terminalTooSmall && narrow.contentWidth == 40,
|
||||
"minimum terminal layout should remain renderable");
|
||||
require(Prism::Tui::calculateLayout(43, 12).terminalTooSmall,
|
||||
"narrow resize should select the compact screen");
|
||||
require(Prism::Tui::calculateLayout(80, 11).terminalTooSmall,
|
||||
"short resize should select the compact screen");
|
||||
}
|
||||
|
||||
void testPipelineAndFakeCapture() {
|
||||
FakeCapture capture;
|
||||
Prism::Capture::StartResult started;
|
||||
std::string error;
|
||||
require(capture.start({}, &started, &error), "fake capture should start");
|
||||
require(!capture.start("missing", &started, &error),
|
||||
"fake capture should reject an unknown selected device");
|
||||
for (int index = 0; index < 20; ++index) {
|
||||
capture.chunks.push_back(sineChunk(1000.0f, 0.25f, 2400, 48000.0f));
|
||||
}
|
||||
|
||||
Prism::Tui::AnalysisPipeline pipeline(48000.0f);
|
||||
bool captureOverrun = false;
|
||||
capture.nextOverwriteCount = 3;
|
||||
std::thread worker([&]() {
|
||||
while (!capture.chunks.empty()) {
|
||||
Prism::Tui::drainCapture(capture, pipeline, captureOverrun, 4);
|
||||
}
|
||||
capture.stop();
|
||||
});
|
||||
worker.join();
|
||||
require(captureOverrun, "capture draining should publish queue overruns");
|
||||
const auto frame = pipeline.snapshot();
|
||||
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,
|
||||
"LUFS pipeline should produce a finite reading");
|
||||
require(std::abs(frame.lufs.momentaryLUFS + 12.03f) < 0.5f,
|
||||
"momentary LUFS should match the deterministic stereo tone");
|
||||
require(std::abs(frame.lufs.integratedLUFS + 12.03f) < 0.5f,
|
||||
"integrated LUFS should match the deterministic stereo tone");
|
||||
|
||||
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));
|
||||
}
|
||||
const auto stereo = stereoPipeline.snapshot();
|
||||
require(stereo.vu.barLDb > stereo.vu.barRDb + 10.0f,
|
||||
"stereo VU values should preserve independent channel levels");
|
||||
pipeline.reset();
|
||||
const auto reset = pipeline.snapshot();
|
||||
require(reset.lufs.integratedLUFS <= -59.0f, "reset should clear integrated loudness");
|
||||
require(capture.stopped, "fake capture should stop cleanly");
|
||||
}
|
||||
|
||||
void testThreadSafeSnapshots() {
|
||||
Prism::Tui::SnapshotStore<size_t> snapshots;
|
||||
constexpr size_t finalValue = 10000;
|
||||
std::thread publisher([&]() {
|
||||
for (size_t value = 1; value <= finalValue; ++value) {
|
||||
snapshots.publish(value);
|
||||
}
|
||||
});
|
||||
size_t observed = 0;
|
||||
while (observed < finalValue) {
|
||||
observed = std::max(observed, snapshots.read());
|
||||
}
|
||||
publisher.join();
|
||||
require(snapshots.read() == finalValue,
|
||||
"immutable display snapshots should publish safely across threads");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
testCli();
|
||||
testProjectionAndLayout();
|
||||
testPipelineAndFakeCapture();
|
||||
testThreadSafeSnapshots();
|
||||
std::cout << "Prism TUI tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user