prism-tui PoC

This commit is contained in:
Boof2015
2026-08-14 13:37:16 -04:00
parent 1a0185306a
commit 7047df3e38
41 changed files with 1995 additions and 635 deletions
+97
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
+51
View File
@@ -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
+37
View File
@@ -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
+62
View File
@@ -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
+29
View File
@@ -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
+166
View File
@@ -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
+39
View File
@@ -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
+89
View File
@@ -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;
}
}
+26
View File
@@ -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
+252
View File
@@ -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
+13
View File
@@ -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
+37
View File
@@ -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()
+223
View File
@@ -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;
}