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
+27 -8
View File
@@ -115,15 +115,17 @@ jobs:
fi
echo "Native module built successfully: $(ls -lh native/build/Release/visualizer_dsp.node)"
# Linux AppImage is app-only. Build it before staging DAW plugins so the
# portable artifact does not carry installable VST3 resources.
- name: Prepare empty Linux plugin resources for AppImage
if: runner.os == 'Linux'
shell: bash
run: |
rm -rf plugin/dist-installer
mkdir -p plugin/dist-installer
- name: Typecheck
run: npm run typecheck
- name: Test native spectrum and capture exports
run: npm run test:spectrum-native
- name: Build and test Prism TUI
run: npm run test:tui
# The AppImage-specific electron-builder config excludes both the native
# terminal frontend and DAW plugins regardless of local staging state.
- name: Build Linux AppImage
if: runner.os == 'Linux'
run: npm run dist:linux:appimage
@@ -250,6 +252,23 @@ jobs:
fi
fi
- name: Stage Prism TUI for installer
run: npm run stage:tui
- name: Smoke-test staged Prism TUI
shell: bash
run: |
tui_binary="tui/dist-installer/prism-tui"
if [ "$RUNNER_OS" = "Windows" ]; then
tui_binary="tui/dist-installer/prism-tui.exe"
fi
if [ ! -f "$tui_binary" ]; then
echo "ERROR: staged prism-tui executable was not found: $tui_binary"
exit 1
fi
"$tui_binary" --help
"$tui_binary" --version
- name: Build distributable
if: runner.os != 'Linux'
run: npm run ${{ matrix.dist_cmd }}
+3
View File
@@ -5,6 +5,9 @@ node_modules/
out/
dist/
native/build/
tui/build/
tui/dist-installer/prism-tui
tui/dist-installer/prism-tui.exe
# JUCE plugin build + installer staging
plugin/build/
+50 -8
View File
@@ -47,6 +47,30 @@ Prism pulls audio at the OS level, straight from CoreAudio on macOS, WASAPI on W
Capture-to-display latency measures under 8ms. When tested at 120fps, measured latency was 0ms. What you see is what you hear.
## Terminal UI
Installable Prism packages also provide `prism-tui`, a native terminal frontend
for the shared C++ capture and analysis engine. It shows a responsive spectrum,
stereo VU meters, and momentary, short-term, and integrated LUFS readings.
```bash
prism-tui # Capture the default system output
prism-tui --list-devices # Print output device IDs; no TTY required
prism-tui --device <id> # Capture a specific output device
prism-tui --help
prism-tui --version
```
Press `r` to reset the analyzers and integrated loudness, or `q`, Escape, or
Ctrl-C to quit. Interactive mode requires a terminal of at least 44 by 12 cells.
Quote a device ID if it contains spaces. Successful help, version, listing, and
interactive exits return `0`; usage errors return `2`; capture and runtime
failures return `1`.
The v0 TUI captures system output only. Microphone/device-input capture, Prism
profiles and themes, file/stdin analysis, and the other visualizers remain GUI
features for now. CoreAudio process tapping requires macOS 14.2 or newer.
## Profiles
Save the whole rack as a profile. What's visible, how it's laid out, per-scope settings, popout window positions as a `.prsm` file. Keep separate profiles for different workflows and share them with others.
@@ -68,7 +92,7 @@ Prebuilt binaries for Windows, macOS, and Linux are available on the [Releases](
## Building from Source
**Prerequisites:** Node.js 18+, npm, and a C++ compiler toolchain.
**Prerequisites:** Node.js 18+, npm, CMake 3.22+, and a C++ compiler toolchain.
| Platform | Toolchain |
|----------|-----------|
@@ -87,17 +111,33 @@ The `postinstall` script compiles the native C++ module for your platform.
```bash
npm run dev # Development
npm run build # Build application assets
npm run dist # Package for current platform
npm run dist:mac # macOS (DMG + ZIP)
npm run configure:tui # Configure the standalone CMake project
npm run build:tui # Build prism-tui into tui/build/bin
npm run test:tui # Build and run native TUI tests
npm run dist # Package for current platform, including prism-tui
npm run dist:mac # macOS (PKG + ZIP)
npm run dist:win # Windows (NSIS + Portable)
npm run dist:linux # Linux (AppImage + DEB + RPM + tar.gz)
```
Linux `.deb` and `.rpm` releases install the seven Prism VST3 plugins to
`/usr/lib/vst3`. The Linux `tar.gz` release includes a `resources/plugins/install-vst3.sh`
helper that installs them to `$HOME/.vst3` by default. The AppImage is app-only;
it does not install DAW plugins. DAWs commonly scan `$HOME/.vst3`, `/usr/lib/vst3`,
and `/usr/local/lib/vst3`.
The TUI build downloads the pinned FTXUI 7.0.1 source through CMake. On Linux it
also requires the PulseAudio development package (`libpulse-dev` on Debian and
Ubuntu). The executable is at `tui/build/bin/prism-tui` on macOS/Linux and
typically `tui/build/bin/Release/prism-tui.exe` with a multi-config Windows
generator.
The macOS PKG links `prism-tui` into `/usr/local/bin`, the Windows NSIS installer
adds Prism's TUI resource directory to the machine `PATH`, and Linux `.deb` and
`.rpm` packages link it into `/usr/bin`. Uninstallers remove only the Prism-owned
PATH entry or link. ZIP, portable Windows, and Linux `tar.gz` archives may carry
the executable under the app's resources but do not modify `PATH`.
Linux `.deb` and `.rpm` releases also install the seven Prism VST3 plugins to
`/usr/lib/vst3`. The Linux `tar.gz` release includes a
`resources/plugins/install-vst3.sh` helper that installs them to `$HOME/.vst3`
by default. The AppImage remains GUI-only: it carries neither installable DAW
plugin resources nor `prism-tui`. DAWs commonly scan `$HOME/.vst3`,
`/usr/lib/vst3`, and `/usr/local/lib/vst3`.
The DAW plugins build with CMake from the [`plugin/`](plugin/) directory. See
[`plugin/README.md`](plugin/README.md) for the per-platform build and install steps.
@@ -115,6 +155,8 @@ If you find Prism useful and want to support a broke college student, consider s
## License
This project is licensed under the [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html). See [LICENSE](LICENSE) for the full text.
Third-party license notices, including FTXUI's MIT license, are recorded in
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
## Star History
+28
View File
@@ -0,0 +1,28 @@
# Third-Party Notices
## FTXUI
Prism TUI statically links [FTXUI](https://github.com/ArthurSonzogni/FTXUI),
version 7.0.1.
The MIT License
Copyright (c) 2019 Arthur Sonzogni.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
const packageJson = require('./package.json')
const excludedResources = new Set(['plugins/', 'tui/'])
module.exports = {
...packageJson.build,
extraResources: packageJson.build.extraResources.filter(
(resource) => !excludedResources.has(resource.to),
),
}
+3 -6
View File
@@ -7,6 +7,7 @@
"cflags_cc": ["-std=c++17", "-O3", "-ffast-math"],
"sources": [
"src/main.cpp",
"src/system_audio_capture_napi.cpp",
"src/oscilloscope.cpp",
"src/spectrum.cpp",
"src/spectrogram.cpp",
@@ -27,8 +28,7 @@
["OS=='mac'", {
"sources": [
"src/macos_capture.mm",
"src/windows_capture_stub.cpp",
"src/linux_capture_stub.cpp"
"src/windows_capture_stub.cpp"
],
"xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
@@ -44,9 +44,7 @@
}],
["OS=='win'", {
"sources": [
"src/macos_capture_stub.cpp",
"src/windows_capture.cpp",
"src/linux_capture_stub.cpp"
"src/windows_capture.cpp"
],
"defines": [
"WIN32_LEAN_AND_MEAN",
@@ -68,7 +66,6 @@
}],
["OS=='linux'", {
"sources": [
"src/macos_capture_stub.cpp",
"src/windows_capture_stub.cpp",
"src/linux_capture.cpp"
],
+77 -149
View File
@@ -1,4 +1,4 @@
#include "linux_capture.h"
#include "system_audio_capture.h"
#if defined(__linux__)
@@ -406,150 +406,111 @@ private:
bool started_ = false;
};
class LinuxNativeCaptureEngine {
class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
public:
Napi::Value GetSupport(const Napi::CallbackInfo& info) const {
Napi::Object support = Napi::Object::New(info.Env());
~LinuxNativeCaptureEngine() override {
stop();
}
Prism::Capture::Support getSupport() const override {
PulseContextConnection connection;
std::string errorMessage;
std::vector<OutputDeviceInfo> devices;
const bool available =
connection.connect("Prism Linux Capture Probe", &errorMessage) &&
connection.enumerateOutputDevices(&devices, &errorMessage);
support.Set("available", Napi::Boolean::New(info.Env(), available));
if (available) {
support.Set("reason", info.Env().Null());
} else {
const std::string reason = errorMessage.empty()
return {
available,
available ? std::string() : (errorMessage.empty()
? "Native Linux capture is unavailable."
: errorMessage;
support.Set("reason", Napi::String::New(info.Env(), reason));
}
return support;
: errorMessage),
};
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) const {
Napi::Env env = info.Env();
Napi::Array devicesArray = Napi::Array::New(env);
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
PulseContextConnection connection;
std::string errorMessage;
std::vector<OutputDeviceInfo> devices;
if (!connection.connect("Prism Linux Capture Devices", &errorMessage) ||
!connection.enumerateOutputDevices(&devices, &errorMessage)) {
return devicesArray;
return {};
}
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set(
"sampleRate",
Napi::Number::New(env, static_cast<double>(device.sampleSpec.rate)));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.sampleSpec.channels)));
devicesArray.Set(static_cast<uint32_t>(index), entry);
std::vector<Prism::Capture::OutputDevice> result;
result.reserve(devices.size());
for (const auto& device : devices) {
result.push_back({
device.id,
device.label,
static_cast<double>(device.sampleSpec.rate),
static_cast<uint32_t>(device.sampleSpec.channels),
device.isDefault,
});
}
return devicesArray;
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string requestedDeviceId;
if (info.Length() > 0 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
std::string errorMessage;
if (!startInternal(requestedDeviceId, &errorMessage)) {
const std::string reason = errorMessage.empty()
? "Native Linux monitor capture failed to start."
: errorMessage;
Napi::Error::New(env, reason)
.ThrowAsJavaScriptException();
return env.Null();
}
std::lock_guard<std::mutex> lock(stateMutex_);
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set("channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceId_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
stopInternal();
return info.Env().Undefined();
bool start(const std::string& requestedDeviceId,
Prism::Capture::StartResult* result,
std::string* errorMessage) override {
if (!startInternal(requestedDeviceId, errorMessage)) {
if (errorMessage != nullptr && errorMessage->empty()) {
*errorMessage = "Native Linux monitor capture failed to start.";
}
return false;
}
if (result != nullptr) {
std::lock_guard<std::mutex> lock(stateMutex_);
result->sampleRate = sampleRate_;
result->channelCount = channelCount_;
result->deviceId = activeDeviceId_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const size_t maxChunks = info.Length() > 0 && info[0].IsNumber()
? std::max<size_t>(1, info[0].As<Napi::Number>().Uint32Value())
: kDefaultDrainChunkLimit;
std::deque<CapturedChunk> drainedChunks;
size_t overwriteCount = 0;
size_t queueDepth = 0;
void stop() override {
stopInternal();
}
Prism::Capture::DrainResult drain(size_t maxChunks) override {
const size_t drainLimit = maxChunks == 0
? kDefaultDrainChunkLimit
: std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
Prism::Capture::DrainResult result;
{
std::lock_guard<std::mutex> lock(chunkMutex_);
const size_t chunkCount = std::min(maxChunks, chunkQueue_.size());
for (size_t index = 0; index < chunkCount; ++index) {
drainedChunks.push_back(std::move(chunkQueue_.front()));
const size_t count = std::min(drainLimit, chunkQueue_.size());
for (size_t index = 0; index < count; ++index) {
drained.push_back(std::move(chunkQueue_.front()));
chunkQueue_.pop_front();
}
overwriteCount = overwriteCount_;
result.overwriteCount = overwriteCount_;
overwriteCount_ = 0;
queueDepth = chunkQueue_.size();
result.queueDepth = chunkQueue_.size();
}
Napi::Array chunks = Napi::Array::New(env, drainedChunks.size());
for (size_t index = 0; index < drainedChunks.size(); ++index) {
const auto& chunk = drainedChunks[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
result.chunks.reserve(drained.size());
while (!drained.empty()) {
auto chunk = std::move(drained.front());
drained.pop_front();
result.chunks.push_back({
std::move(chunk.left),
std::move(chunk.right),
chunk.channelCount,
chunk.capturedAtMilliseconds,
chunk.sequence,
});
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount", Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(queueDepth)));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) const {
return Napi::Number::New(info.Env(), monotonicMilliseconds());
double nowMilliseconds() const override {
return monotonicMilliseconds();
}
const char* backendName() const override {
return "PulseAudio";
}
private:
@@ -885,47 +846,14 @@ private:
size_t overwriteCount_ = 0;
};
LinuxNativeCaptureEngine& GetLinuxNativeCaptureEngine() {
static LinuxNativeCaptureEngine engine;
return engine;
}
Napi::Value LinuxGetSupport(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().GetSupport(info);
}
Napi::Value LinuxListOutputDevices(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().ListOutputDevices(info);
}
Napi::Value LinuxStart(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().Start(info);
}
Napi::Value LinuxStop(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().Stop(info);
}
Napi::Value LinuxDrain(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().Drain(info);
}
Napi::Value LinuxNowMilliseconds(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().NowMilliseconds(info);
}
} // namespace
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, LinuxGetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, LinuxListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, LinuxStart));
captureExports.Set("stop", Napi::Function::New(env, LinuxStop));
captureExports.Set("drain", Napi::Function::New(env, LinuxDrain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, LinuxNowMilliseconds));
exports.Set("linuxCapture", captureExports);
namespace Prism::Capture {
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
return std::make_unique<LinuxNativeCaptureEngine>();
}
} // namespace Prism::Capture
#endif
-5
View File
@@ -1,5 +0,0 @@
#pragma once
#include <napi.h>
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports);
-54
View File
@@ -1,54 +0,0 @@
#include "linux_capture.h"
namespace {
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(
info.Env(), "Native Linux output-device capture is unavailable on this platform."));
return support;
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "Native Linux output-device capture is unavailable on this platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
} // namespace
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, Start));
captureExports.Set("stop", Napi::Function::New(env, Stop));
captureExports.Set("drain", Napi::Function::New(env, Drain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set("linuxCapture", captureExports);
}
-5
View File
@@ -1,5 +0,0 @@
#pragma once
#include <napi.h>
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports);
+68 -150
View File
@@ -1,4 +1,4 @@
#include "macos_capture.h"
#include "system_audio_capture.h"
#if defined(__APPLE__)
@@ -319,140 +319,104 @@ std::string formatStatusMessage(const char* operation, OSStatus status) {
return std::string(operation) + " failed (" + std::to_string(static_cast<int>(status)) + ")";
}
class MacOSNativeCaptureEngine {
class MacOSNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
public:
Napi::Object GetSupport(Napi::Env env) {
Napi::Object support = Napi::Object::New(env);
if (@available(macOS 14.2, *)) {
support.Set("available", Napi::Boolean::New(env, true));
support.Set("reason", env.Null());
return support;
}
support.Set("available", Napi::Boolean::New(env, false));
support.Set(
"reason",
Napi::String::New(env, "Native output-device capture requires macOS 14.2 or newer."));
return support;
~MacOSNativeCaptureEngine() override {
stop();
}
Napi::Array ListOutputDevices(Napi::Env env) {
Napi::Array result = Napi::Array::New(env);
Prism::Capture::Support getSupport() const override {
if (@available(macOS 14.2, *)) {
return {true, {}};
}
return {false, "Native output-device capture requires macOS 14.2 or newer."};
}
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
std::vector<Prism::Capture::OutputDevice> result;
if (!isSupported()) {
return result;
}
const auto devices = enumerateOutputDevices();
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.uid));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.channelCount)));
result.Set(static_cast<uint32_t>(index), entry);
result.reserve(devices.size());
for (const auto& device : devices) {
result.push_back({
device.uid,
device.label,
device.sampleRate,
static_cast<uint32_t>(device.channelCount),
device.isDefault,
});
}
return result;
}
Napi::Object Start(Napi::Env env, const std::string& requestedDeviceUid) {
Napi::Object result = Napi::Object::New(env);
const Napi::Object support = GetSupport(env);
if (!support.Get("available").As<Napi::Boolean>().Value()) {
Napi::Error::New(
env, support.Get("reason").As<Napi::String>().Utf8Value())
.ThrowAsJavaScriptException();
return result;
bool start(const std::string& requestedDeviceId,
Prism::Capture::StartResult* result,
std::string* errorMessage) override {
const auto support = getSupport();
if (!support.available) {
if (errorMessage != nullptr) {
*errorMessage = support.reason;
}
return false;
}
std::string errorMessage;
if (!startInternal(requestedDeviceUid, &errorMessage)) {
Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
return result;
if (!startInternal(requestedDeviceId, errorMessage)) {
return false;
}
std::lock_guard<std::mutex> lock(stateMutex_);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set("channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceUid_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
if (result != nullptr) {
std::lock_guard<std::mutex> lock(stateMutex_);
result->sampleRate = sampleRate_;
result->channelCount = static_cast<uint32_t>(channelCount_);
result->deviceId = activeDeviceUid_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
}
void Stop() {
void stop() override {
std::lock_guard<std::mutex> lock(stateMutex_);
stopLocked();
}
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
Prism::Capture::DrainResult drain(size_t maxChunks) override {
const size_t drainLimit =
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
uint64_t overwriteCount = 0;
Prism::Capture::DrainResult result;
{
std::lock_guard<std::mutex> queueLock(chunkMutex_);
overwriteCount = overwriteCount_;
result.overwriteCount = overwriteCount_;
const size_t count = std::min(drainLimit, chunkQueue_.size());
for (size_t index = 0; index < count; ++index) {
drained.push_back(std::move(chunkQueue_.front()));
chunkQueue_.pop_front();
}
result.queueDepth = chunkQueue_.size();
}
Napi::Array chunks = Napi::Array::New(env, drained.size());
for (size_t index = 0; index < drained.size(); ++index) {
CapturedChunk& chunk = drained[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left =
Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right =
Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(
left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(
right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
result.chunks.reserve(drained.size());
while (!drained.empty()) {
auto chunk = std::move(drained.front());
drained.pop_front();
result.chunks.push_back({
std::move(chunk.left),
std::move(chunk.right),
static_cast<uint32_t>(chunk.channelCount),
chunk.capturedAtMilliseconds,
chunk.sequence,
});
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount",
Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set(
"queueDepth",
Napi::Number::New(env, static_cast<double>(chunkQueue_.size())));
return result;
}
double NowMilliseconds() const {
double nowMilliseconds() const override {
return monotonicMilliseconds();
}
const char* backendName() const override {
return "CoreAudio";
}
private:
static OSStatus StaticIOProc(AudioObjectID inDevice,
const AudioTimeStamp* inNow,
@@ -780,60 +744,14 @@ private:
UInt32 channelCount_ = 2;
};
MacOSNativeCaptureEngine& engine() {
static MacOSNativeCaptureEngine instance;
return instance;
}
Napi::Value MacOSGetSupport(const Napi::CallbackInfo& info) {
return engine().GetSupport(info.Env());
}
Napi::Value MacOSListOutputDevices(const Napi::CallbackInfo& info) {
return engine().ListOutputDevices(info.Env());
}
Napi::Value MacOSStart(const Napi::CallbackInfo& info) {
std::string requestedDeviceUid;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceUid = info[0].As<Napi::String>().Utf8Value();
}
return engine().Start(info.Env(), requestedDeviceUid);
}
Napi::Value MacOSStop(const Napi::CallbackInfo& info) {
engine().Stop();
return info.Env().Undefined();
}
Napi::Value MacOSDrain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = static_cast<size_t>(requested);
}
}
return engine().Drain(info.Env(), maxChunks);
}
Napi::Value MacOSNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
}
} // namespace
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, MacOSGetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, MacOSListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, MacOSStart));
captureExports.Set("stop", Napi::Function::New(env, MacOSStop));
captureExports.Set("drain", Napi::Function::New(env, MacOSDrain));
captureExports.Set(
"nowMilliseconds", Napi::Function::New(env, MacOSNowMilliseconds));
exports.Set("macosCapture", captureExports);
namespace Prism::Capture {
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
return std::make_unique<MacOSNativeCaptureEngine>();
}
} // namespace Prism::Capture
#endif // defined(__APPLE__)
-54
View File
@@ -1,54 +0,0 @@
#include "macos_capture.h"
namespace {
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(
info.Env(), "Native macOS output-device capture is unavailable on this platform."));
return support;
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "Native macOS output-device capture is unavailable on this platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
} // namespace
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, Start));
captureExports.Set("stop", Napi::Function::New(env, Stop));
captureExports.Set("drain", Napi::Function::New(env, Drain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set("macosCapture", captureExports);
}
+3 -5
View File
@@ -2,8 +2,7 @@
#include <algorithm>
#include <cstring>
#include <string>
#include "linux_capture.h"
#include "macos_capture.h"
#include "system_audio_capture_napi.h"
#include "windows_capture.h"
#include "oscilloscope.h"
#include "spectrum.h"
@@ -765,9 +764,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
lufsExports.Set("reset", Napi::Function::New(env, LUFSMeterReset));
exports.Set("lufsmeter", lufsExports);
RegisterMacOSCapture(env, exports);
RegisterWindowsCapture(env, exports);
RegisterLinuxCapture(env, exports);
RegisterSystemAudioCapture(env, exports);
RegisterWindowsMedia(env, exports);
RegisterWindowChrome(env, exports);
return exports;
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace Prism::Capture {
struct Support {
bool available = false;
std::string reason;
};
struct OutputDevice {
std::string id;
std::string label;
double sampleRate = 48000.0;
uint32_t channelCount = 2;
bool isDefault = false;
};
struct StartResult {
double sampleRate = 48000.0;
uint32_t channelCount = 2;
std::string deviceId;
std::string deviceLabel;
};
struct AudioChunk {
std::vector<float> left;
std::vector<float> right;
uint32_t channelCount = 2;
double capturedAtMilliseconds = 0.0;
uint64_t sequence = 0;
};
struct DrainResult {
std::vector<AudioChunk> chunks;
uint64_t overwriteCount = 0;
size_t queueDepth = 0;
};
class SystemAudioCapture {
public:
virtual ~SystemAudioCapture() = default;
virtual Support getSupport() const = 0;
virtual std::vector<OutputDevice> listOutputDevices() = 0;
virtual bool start(const std::string& requestedDeviceId,
StartResult* result,
std::string* errorMessage) = 0;
virtual void stop() = 0;
virtual DrainResult drain(size_t maxChunks = 64) = 0;
virtual double nowMilliseconds() const = 0;
virtual const char* backendName() const = 0;
};
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture();
} // namespace Prism::Capture
+206
View File
@@ -0,0 +1,206 @@
#include "system_audio_capture_napi.h"
#include "system_audio_capture.h"
#include <algorithm>
#include <cstring>
#include <memory>
#include <string>
namespace {
constexpr size_t kDefaultDrainChunkLimit = 64;
constexpr size_t kMaxDrainChunkLimit = 256;
std::unique_ptr<Prism::Capture::SystemAudioCapture>& activeCapture() {
static std::unique_ptr<Prism::Capture::SystemAudioCapture> capture =
Prism::Capture::createSystemAudioCapture();
return capture;
}
const char* activeExportName() {
#if defined(__APPLE__)
return "macosCapture";
#elif defined(_WIN32)
return "windowsCapture";
#else
return "linuxCapture";
#endif
}
Napi::Object supportToNapi(Napi::Env env, const Prism::Capture::Support& support) {
Napi::Object result = Napi::Object::New(env);
result.Set("available", Napi::Boolean::New(env, support.available));
if (support.available || support.reason.empty()) {
result.Set("reason", env.Null());
} else {
result.Set("reason", Napi::String::New(env, support.reason));
}
return result;
}
Napi::Object drainToNapi(Napi::Env env, Prism::Capture::DrainResult&& drained) {
Napi::Array chunks = Napi::Array::New(env, drained.chunks.size());
for (size_t index = 0; index < drained.chunks.size(); ++index) {
auto& chunk = drained.chunks[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set("channelCount", Napi::Number::New(env, chunk.channelCount));
entry.Set("capturedAtMilliseconds", Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set("sequence", Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set("overwriteCount", Napi::Number::New(env, static_cast<double>(drained.overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(drained.queueDepth)));
return result;
}
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
return supportToNapi(info.Env(), activeCapture()->getSupport());
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const auto devices = activeCapture()->listOutputDevices();
Napi::Array result = Napi::Array::New(env, devices.size());
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set("channelCount", Napi::Number::New(env, device.channelCount));
result.Set(static_cast<uint32_t>(index), entry);
}
return result;
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string requestedDeviceId;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
Prism::Capture::StartResult started;
std::string errorMessage;
if (!activeCapture()->start(requestedDeviceId, &started, &errorMessage)) {
Napi::Error::New(env, errorMessage.empty() ? "System audio capture failed to start." : errorMessage)
.ThrowAsJavaScriptException();
return env.Undefined();
}
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, started.sampleRate));
result.Set("channelCount", Napi::Number::New(env, started.channelCount));
result.Set("deviceId", Napi::String::New(env, started.deviceId));
result.Set("deviceLabel", Napi::String::New(env, started.deviceLabel));
return result;
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
activeCapture()->stop();
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = std::min(static_cast<size_t>(requested), kMaxDrainChunkLimit);
}
}
return drainToNapi(info.Env(), activeCapture()->drain(maxChunks));
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), activeCapture()->nowMilliseconds());
}
void RegisterCaptureObject(Napi::Env env, Napi::Object exports, const char* exportName) {
Napi::Object capture = Napi::Object::New(env);
capture.Set("getSupport", Napi::Function::New(env, GetSupport));
capture.Set("listOutputDevices", Napi::Function::New(env, ListOutputDevices));
capture.Set("start", Napi::Function::New(env, Start));
capture.Set("stop", Napi::Function::New(env, Stop));
capture.Set("drain", Napi::Function::New(env, Drain));
capture.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set(exportName, capture);
}
Napi::Value UnavailableGetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(info.Env(), "This system-audio capture backend is unavailable on the current platform."));
return support;
}
Napi::Value UnavailableListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value UnavailableStart(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "This system-audio capture backend is unavailable on the current platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value UnavailableStop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value UnavailableDrain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value UnavailableNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
void RegisterUnavailableCaptureObject(Napi::Env env, Napi::Object exports, const char* exportName) {
Napi::Object capture = Napi::Object::New(env);
capture.Set("getSupport", Napi::Function::New(env, UnavailableGetSupport));
capture.Set("listOutputDevices", Napi::Function::New(env, UnavailableListOutputDevices));
capture.Set("start", Napi::Function::New(env, UnavailableStart));
capture.Set("stop", Napi::Function::New(env, UnavailableStop));
capture.Set("drain", Napi::Function::New(env, UnavailableDrain));
capture.Set("nowMilliseconds", Napi::Function::New(env, UnavailableNowMilliseconds));
exports.Set(exportName, capture);
}
} // namespace
void RegisterSystemAudioCapture(Napi::Env env, Napi::Object exports) {
RegisterCaptureObject(env, exports, activeExportName());
#if !defined(__APPLE__)
RegisterUnavailableCaptureObject(env, exports, "macosCapture");
#endif
#if !defined(_WIN32)
RegisterUnavailableCaptureObject(env, exports, "windowsCapture");
#endif
#if defined(__APPLE__) || defined(_WIN32)
RegisterUnavailableCaptureObject(env, exports, "linuxCapture");
#endif
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <napi.h>
void RegisterSystemAudioCapture(Napi::Env env, Napi::Object exports);
+77 -129
View File
@@ -1,4 +1,7 @@
#ifndef PRISM_CAPTURE_CORE_ONLY
#include "windows_capture.h"
#endif
#include "system_audio_capture.h"
#if defined(_WIN32)
@@ -10,14 +13,16 @@
#include <mmreg.h>
#include <propidl.h>
#include <avrt.h>
#include <roapi.h>
#include <wrl/client.h>
#include <windows.h>
#ifndef PRISM_CAPTURE_CORE_ONLY
#include <roapi.h>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Media.Control.h>
#include <winrt/Windows.Storage.Streams.h>
#endif
#include <algorithm>
#include <atomic>
@@ -39,9 +44,11 @@
namespace {
using Microsoft::WRL::ComPtr;
#ifndef PRISM_CAPTURE_CORE_ONLY
using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSession;
using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager;
using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus;
#endif
constexpr size_t kMaxQueuedChunks = 256;
constexpr size_t kDefaultDrainChunkLimit = 64;
@@ -131,6 +138,7 @@ std::string hresultMessage(const char* operation, HRESULT hr) {
return stream.str();
}
#ifndef PRISM_CAPTURE_CORE_ONLY
std::string winrtErrorMessage(const char* operation, const winrt::hresult_error& error) {
std::string message = hresultMessage(operation, error.code().value);
const std::wstring detailWide = error.message().c_str();
@@ -165,6 +173,7 @@ std::string playbackStatusToString(GlobalSystemMediaTransportControlsSessionPlay
return "Closed";
}
}
#endif
class ScopedCoInit {
public:
@@ -191,6 +200,7 @@ private:
bool usable_;
};
#ifndef PRISM_CAPTURE_CORE_ONLY
class ScopedRoInit {
public:
ScopedRoInit()
@@ -344,6 +354,7 @@ std::string getOrFetchThumbnail(
}
return result;
}
#endif
std::string getDeviceId(IMMDevice* device) {
if (device == nullptr) {
@@ -584,114 +595,90 @@ std::vector<OutputDeviceInfo> enumerateOutputDevices() {
return devices;
}
class WindowsNativeCaptureEngine {
class WindowsNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
public:
Napi::Object GetSupport(Napi::Env env) {
Napi::Object support = Napi::Object::New(env);
support.Set("available", Napi::Boolean::New(env, true));
support.Set("reason", env.Null());
return support;
~WindowsNativeCaptureEngine() override {
stop();
}
Napi::Array ListOutputDevices(Napi::Env env) {
Prism::Capture::Support getSupport() const override {
return {true, {}};
}
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
const auto devices = enumerateOutputDevices();
Napi::Array result = Napi::Array::New(env, devices.size());
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.channelCount)));
result.Set(static_cast<uint32_t>(index), entry);
std::vector<Prism::Capture::OutputDevice> result;
result.reserve(devices.size());
for (const auto& device : devices) {
result.push_back({
device.id,
device.label,
device.sampleRate,
static_cast<uint32_t>(device.channelCount),
device.isDefault,
});
}
return result;
}
Napi::Object Start(Napi::Env env, const std::string& requestedDeviceId) {
std::string errorMessage;
if (!startInternal(requestedDeviceId, &errorMessage)) {
Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
return Napi::Object::New(env);
bool start(const std::string& requestedDeviceId,
Prism::Capture::StartResult* result,
std::string* errorMessage) override {
if (!startInternal(requestedDeviceId, errorMessage)) {
return false;
}
std::lock_guard<std::mutex> lock(stateMutex_);
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set(
"channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceId_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
if (result != nullptr) {
std::lock_guard<std::mutex> lock(stateMutex_);
result->sampleRate = sampleRate_;
result->channelCount = static_cast<uint32_t>(channelCount_);
result->deviceId = activeDeviceId_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
}
void Stop() {
void stop() override {
stopInternal();
}
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
Prism::Capture::DrainResult drain(size_t maxChunks) override {
const size_t drainLimit =
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
uint64_t overwriteCount = 0;
size_t queueDepth = 0;
Prism::Capture::DrainResult result;
{
std::lock_guard<std::mutex> lock(chunkMutex_);
overwriteCount = overwriteCount_;
result.overwriteCount = overwriteCount_;
const size_t count = std::min(drainLimit, chunkQueue_.size());
for (size_t index = 0; index < count; ++index) {
drained.push_back(std::move(chunkQueue_.front()));
chunkQueue_.pop_front();
}
queueDepth = chunkQueue_.size();
result.queueDepth = chunkQueue_.size();
}
Napi::Array chunks = Napi::Array::New(env, drained.size());
for (size_t index = 0; index < drained.size(); ++index) {
auto& chunk = drained[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
result.chunks.reserve(drained.size());
while (!drained.empty()) {
auto chunk = std::move(drained.front());
drained.pop_front();
result.chunks.push_back({
std::move(chunk.left),
std::move(chunk.right),
static_cast<uint32_t>(chunk.channelCount),
chunk.capturedAtMilliseconds,
chunk.sequence,
});
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount", Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(queueDepth)));
return result;
}
double NowMilliseconds() const {
double nowMilliseconds() const override {
return monotonicMilliseconds();
}
const char* backendName() const override {
return "WASAPI";
}
private:
bool startInternal(const std::string& requestedDeviceId, std::string* outErrorMessage) {
stopInternal();
@@ -1063,47 +1050,7 @@ private:
UINT32 channelCount_ = 2;
};
WindowsNativeCaptureEngine& engine() {
static WindowsNativeCaptureEngine instance;
return instance;
}
Napi::Value WindowsGetSupport(const Napi::CallbackInfo& info) {
return engine().GetSupport(info.Env());
}
Napi::Value WindowsListOutputDevices(const Napi::CallbackInfo& info) {
return engine().ListOutputDevices(info.Env());
}
Napi::Value WindowsStart(const Napi::CallbackInfo& info) {
std::string requestedDeviceId;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
return engine().Start(info.Env(), requestedDeviceId);
}
Napi::Value WindowsStop(const Napi::CallbackInfo& info) {
engine().Stop();
return info.Env().Undefined();
}
Napi::Value WindowsDrain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = static_cast<size_t>(requested);
}
}
return engine().Drain(info.Env(), maxChunks);
}
Napi::Value WindowsNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
}
#ifndef PRISM_CAPTURE_CORE_ONLY
Napi::Value WindowsMediaGetSupport(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
@@ -1264,20 +1211,12 @@ Napi::Value WindowsMediaSendSpotifyControl(const Napi::CallbackInfo& info) {
return env.Null();
}
}
#endif
} // namespace
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, WindowsGetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, WindowsListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, WindowsStart));
captureExports.Set("stop", Napi::Function::New(env, WindowsStop));
captureExports.Set("drain", Napi::Function::New(env, WindowsDrain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, WindowsNowMilliseconds));
exports.Set("windowsCapture", captureExports);
#ifndef PRISM_CAPTURE_CORE_ONLY
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
Napi::Object mediaExports = Napi::Object::New(env);
mediaExports.Set("getSupport", Napi::Function::New(env, WindowsMediaGetSupport));
mediaExports.Set(
@@ -1288,5 +1227,14 @@ void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
Napi::Function::New(env, WindowsMediaSendSpotifyControl));
exports.Set("windowsMedia", mediaExports);
}
#endif
namespace Prism::Capture {
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
return std::make_unique<WindowsNativeCaptureEngine>();
}
} // namespace Prism::Capture
#endif // defined(_WIN32)
+1 -1
View File
@@ -2,4 +2,4 @@
#include <napi.h>
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports);
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports);
+1 -48
View File
@@ -2,43 +2,6 @@
namespace {
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(
info.Env(), "Native Windows output-device capture is unavailable on this platform."));
return support;
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "Native Windows output-device capture is unavailable on this platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
Napi::Value MediaGetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
@@ -62,17 +25,7 @@ Napi::Value SendSpotifyControl(const Napi::CallbackInfo& info) {
} // namespace
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, Start));
captureExports.Set("stop", Napi::Function::New(env, Stop));
captureExports.Set("drain", Napi::Function::New(env, Drain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set("windowsCapture", captureExports);
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
Napi::Object mediaExports = Napi::Object::New(env);
mediaExports.Set("getSupport", Napi::Function::New(env, MediaGetSupport));
mediaExports.Set(
+20 -6
View File
@@ -22,19 +22,23 @@
"test:window-state": "node scripts/run-window-state-tests.mjs",
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
"test:spectrum-native": "node --test test/spectrum-native.test.mjs",
"test:tui": "node scripts/build/build-tui.cjs --test",
"test:build-metadata": "node scripts/run-build-metadata-tests.mjs",
"test:updates": "node scripts/run-update-tests.mjs",
"plugin-ui:dev": "vite --config vite.plugin-ui.config.ts",
"plugin-ui:build": "vite build --config vite.plugin-ui.config.ts",
"build:native": "cd native && node-gyp rebuild",
"configure:tui": "node scripts/build/build-tui.cjs --configure-only",
"build:tui": "node scripts/build/build-tui.cjs",
"stage:tui": "node scripts/build/build-tui.cjs --stage",
"rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"",
"postinstall": "node scripts/build/postinstall-native.cjs",
"dist": "npm run build && electron-builder --publish never",
"dist:mac": "npm run build && electron-builder --mac --publish never",
"dist:win": "npm run build && electron-builder --win --publish never",
"dist:linux": "npm run build && electron-builder --linux AppImage deb rpm tar.gz --publish never",
"dist:linux:appimage": "npm run build && electron-builder --linux AppImage --publish never",
"dist:linux:packages": "electron-builder --linux deb rpm tar.gz --publish never"
"dist": "node scripts/build/dist-current.cjs",
"dist:mac": "npm run stage:tui && npm run build && electron-builder --mac --publish never",
"dist:win": "npm run stage:tui && npm run build && electron-builder --win --publish never",
"dist:linux": "npm run dist:linux:appimage && npm run stage:tui && electron-builder --linux deb rpm tar.gz --publish never",
"dist:linux:appimage": "npm run build && electron-builder --config electron-builder.appimage.cjs --linux AppImage --publish never",
"dist:linux:packages": "npm run stage:tui && electron-builder --linux deb rpm tar.gz --publish never"
},
"repository": {
"type": "git",
@@ -99,6 +103,14 @@
{
"from": "plugin/dist-installer/",
"to": "plugins/"
},
{
"from": "tui/dist-installer/",
"to": "tui/"
},
{
"from": "THIRD_PARTY_NOTICES.md",
"to": "THIRD_PARTY_NOTICES.md"
}
],
"mac": {
@@ -144,6 +156,7 @@
"libatspi2.0-0",
"libuuid1",
"libsecret-1-0",
"libpulse0",
"libwebkit2gtk-4.1-0"
]
},
@@ -160,6 +173,7 @@
"at-spi2-core",
"libuuid",
"libsecret",
"pulseaudio-libs",
"webkit2gtk4.1"
]
}
@@ -1,11 +1,51 @@
#!/bin/sh
# Package post-install hook for Linux .deb/.rpm builds. The app package installs
# Prism under /opt, then this copies the bundled native Linux VST3 bundles into
# the global VST3 scan path used by Linux DAWs.
# Prism under /opt, then this exposes prism-tui on PATH and copies the bundled
# native Linux VST3 bundles into the global VST3 scan path used by Linux DAWs.
set -eu
DEST_DIR="${PRISM_VST3_DEST_DIR:-/usr/lib/vst3}"
TUI_LINK="${PRISM_TUI_LINK_PATH:-/usr/bin/prism-tui}"
find_tui() {
if [ -n "${PRISM_TUI_SOURCE_PATH:-}" ] && [ -f "$PRISM_TUI_SOURCE_PATH" ]; then
printf '%s\n' "$PRISM_TUI_SOURCE_PATH"
return 0
fi
for executable in \
/opt/Prism/resources/tui/prism-tui \
/opt/prism/resources/tui/prism-tui
do
if [ -f "$executable" ]; then
printf '%s\n' "$executable"
return 0
fi
done
return 1
}
install_tui() {
tui_source="$(find_tui || true)"
if [ -z "$tui_source" ]; then
echo "Prism TUI install: bundled executable not found; skipping PATH link." >&2
return 0
fi
chmod 755 "$tui_source"
if [ -e "$TUI_LINK" ] || [ -L "$TUI_LINK" ]; then
if [ -L "$TUI_LINK" ] && [ "$(readlink "$TUI_LINK")" = "$tui_source" ]; then
return 0
fi
echo "Prism TUI install: $TUI_LINK already exists and was left unchanged." >&2
return 0
fi
ln -s "$tui_source" "$TUI_LINK"
echo "Prism TUI installed at $TUI_LINK"
}
find_source_dir() {
if [ -n "${PRISM_VST3_SOURCE_DIR:-}" ] && [ -d "$PRISM_VST3_SOURCE_DIR" ]; then
@@ -40,6 +80,8 @@ install_plugin() {
cp -a "$source_plugin" "$DEST_DIR/"
}
install_tui
SOURCE_DIR="$(find_source_dir || true)"
if [ -z "$SOURCE_DIR" ]; then
echo "Prism VST3 install: bundled VST3 directory not found; skipping plugin install." >&2
@@ -4,7 +4,42 @@
set -eu
# Debian postrm reports upgrades by name; RPM postun reports the number of
# installed package versions remaining. Keep shared resources during either
# upgrade path and remove them only on a real uninstall.
remove_action="${1:-}"
case "$remove_action" in
upgrade|failed-upgrade|abort-*)
exit 0
;;
''|*[!0-9]*)
;;
*)
if [ "$remove_action" -gt 0 ]; then
exit 0
fi
;;
esac
DEST_DIR="${PRISM_VST3_DEST_DIR:-/usr/lib/vst3}"
TUI_LINK="${PRISM_TUI_LINK_PATH:-/usr/bin/prism-tui}"
remove_tui_link() {
[ -L "$TUI_LINK" ] || return 0
tui_target="$(readlink "$TUI_LINK")"
case "$tui_target" in
/opt/Prism/resources/tui/prism-tui|/opt/prism/resources/tui/prism-tui)
rm "$TUI_LINK"
;;
*)
if [ -n "${PRISM_TUI_SOURCE_PATH:-}" ] && [ "$tui_target" = "$PRISM_TUI_SOURCE_PATH" ]; then
rm "$TUI_LINK"
else
echo "Prism TUI removal: $TUI_LINK points elsewhere and was left unchanged." >&2
fi
;;
esac
}
remove_plugin() {
rm -rf "$DEST_DIR/$1"
@@ -17,5 +52,6 @@ remove_plugin "Prism Loudness Meter.vst3"
remove_plugin "Prism Vectorscope.vst3"
remove_plugin "Prism Spectrogram.vst3"
remove_plugin "Prism Waveform.vst3"
remove_tui_link
echo "Prism VST3 plugins removed from $DEST_DIR"
+19 -3
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Postinstall: after the macOS Installer drops Prism.app into /Applications, copy
# the bundled VST3 / AU plugins out of the app's Resources into the system
# Audio Plug-Ins folders so DAWs find them.
# Audio Plug-Ins folders so DAWs find them, and expose prism-tui on PATH.
#
# Standard pkg postinstall args:
# $1 = full path to the installer package
@@ -17,8 +17,10 @@ APP_PATH="${2:-/Applications}/Prism.app"
[ -d "$APP_PATH" ] || APP_PATH="/Applications/Prism.app"
PLUGINS_SRC="$APP_PATH/Contents/Resources/plugins"
VST3_DEST="/Library/Audio/Plug-Ins/VST3"
AU_DEST="/Library/Audio/Plug-Ins/Components"
VST3_DEST="${PRISM_VST3_DEST_DIR:-/Library/Audio/Plug-Ins/VST3}"
AU_DEST="${PRISM_AU_DEST_DIR:-/Library/Audio/Plug-Ins/Components}"
TUI_SOURCE="$APP_PATH/Contents/Resources/tui/prism-tui"
TUI_LINK="${PRISM_TUI_LINK_PATH:-/usr/local/bin/prism-tui}"
mkdir -p "$VST3_DEST" "$AU_DEST"
@@ -29,4 +31,18 @@ if [ -d "$PLUGINS_SRC/AU" ]; then
cp -R "$PLUGINS_SRC/AU/"*.component "$AU_DEST/" 2>/dev/null || true
fi
if [ -f "$TUI_SOURCE" ]; then
chmod 755 "$TUI_SOURCE"
mkdir -p "$(dirname "$TUI_LINK")"
if [ -e "$TUI_LINK" ] || [ -L "$TUI_LINK" ]; then
if [ ! -L "$TUI_LINK" ] || [ "$(readlink "$TUI_LINK")" != "$TUI_SOURCE" ]; then
echo "Prism postinstall: $TUI_LINK already exists and was left unchanged." >&2
fi
else
ln -s "$TUI_SOURCE" "$TUI_LINK"
fi
else
echo "Prism postinstall: bundled prism-tui not found at $TUI_SOURCE" >&2
fi
exit 0
+32 -1
View File
@@ -11,9 +11,11 @@
; to Common Files succeed without a second elevation. On uninstall, the bundles
; are removed unconditionally (harmless if the user opted out at install time).
!include "LogicLib.nsh"
!include "WinMessages.nsh"
!ifndef BUILD_UNINSTALLER
!include "nsDialogs.nsh"
!include "LogicLib.nsh"
Var PRISM_VST_CHECKBOX
Var PRISM_VST_STATE
@@ -53,6 +55,25 @@
!macroend
!macro customInstall
${IfNot} ${FileExists} "$INSTDIR\resources\tui\prism-tui.exe"
DetailPrint "ERROR: bundled prism-tui.exe was not found"
MessageBox MB_OK|MB_ICONSTOP "The bundled prism-tui executable was not found.$\r$\n$\r$\nMissing path:$\r$\n$INSTDIR\resources\tui\prism-tui.exe" /SD IDOK
Abort
${EndIf}
; PowerShell avoids NSIS string-length truncation on machines with a large
; PATH. It removes exact duplicates, appends Prism once, and leaves every
; other entry intact. PowerShell comparisons are case-insensitive here.
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param([string]$$entry) $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry }); $$entries += $$entry; [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine') }" "$INSTDIR\resources\tui"`
Pop $0
${If} $0 != 0
DetailPrint "ERROR: could not add Prism TUI to the machine PATH (exit code $0)"
MessageBox MB_OK|MB_ICONSTOP "Prism could not add prism-tui to the machine PATH.$\r$\n$\r$\nPowerShell exit code: $0" /SD IDOK
Abort
${EndIf}
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
DetailPrint "Added $INSTDIR\resources\tui to the machine PATH"
${If} $PRISM_VST_STATE == ${BST_CHECKED}
DetailPrint "Installing Prism VST3 plugins to $COMMONFILES64\VST3"
@@ -79,6 +100,16 @@
!endif
!macro customUnInstall
; Remove only Prism's exact machine-PATH entry. A failure is non-fatal so an
; otherwise valid uninstall is never blocked.
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param([string]$$entry) $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry }); [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine') }" "$INSTDIR\resources\tui"`
Pop $0
${If} $0 == 0
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
${Else}
DetailPrint "WARNING: could not remove Prism TUI from the machine PATH (exit code $0)"
${EndIf}
; Always attempt removal — harmless RMDir if the bundle isn't there (user opted
; out at install or removed manually).
RMDir /r "$COMMONFILES64\VST3\Prism Spectrum.vst3"
+49
View File
@@ -0,0 +1,49 @@
const { copyFileSync, existsSync, mkdirSync, chmodSync } = require('node:fs')
const { join } = require('node:path')
const { spawnSync } = require('node:child_process')
const rootDir = join(__dirname, '..', '..')
const buildDir = join(rootDir, 'tui', 'build')
const packageJson = require(join(rootDir, 'package.json'))
const shouldTest = process.argv.includes('--test')
const shouldStage = process.argv.includes('--stage')
const configureOnly = process.argv.includes('--configure-only')
function run(command, args) {
const result = spawnSync(command, args, { cwd: rootDir, stdio: 'inherit' })
if (result.status !== 0) process.exit(result.status ?? 1)
}
run('cmake', [
'-S', 'tui',
'-B', 'tui/build',
'-DCMAKE_BUILD_TYPE=Release',
`-DPRISM_VERSION=${packageJson.version}`,
])
if (configureOnly) process.exit(0)
run('cmake', ['--build', 'tui/build', '--config', 'Release', '--parallel', '4'])
if (shouldTest) {
run('ctest', ['--test-dir', 'tui/build', '-C', 'Release', '--output-on-failure'])
}
if (shouldStage) {
const executableName = process.platform === 'win32' ? 'prism-tui.exe' : 'prism-tui'
const candidates = [
join(buildDir, 'bin', executableName),
join(buildDir, 'bin', 'Release', executableName),
join(buildDir, 'Release', executableName),
]
const source = candidates.find(existsSync)
if (!source) {
console.error(`Could not find built ${executableName}. Checked:\n${candidates.join('\n')}`)
process.exit(1)
}
const stageDir = join(rootDir, 'tui', 'dist-installer')
mkdirSync(stageDir, { recursive: true })
const destination = join(stageDir, executableName)
copyFileSync(source, destination)
if (process.platform !== 'win32') chmodSync(destination, 0o755)
console.log(`Staged ${destination}`)
}
+17
View File
@@ -0,0 +1,17 @@
const { spawnSync } = require('node:child_process')
const scriptByPlatform = {
darwin: 'dist:mac',
linux: 'dist:linux',
win32: 'dist:win',
}
const script = scriptByPlatform[process.platform]
if (!script) {
console.error(`Packaging is not configured for ${process.platform}.`)
process.exit(1)
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const result = spawnSync(npmCommand, ['run', script], { stdio: 'inherit' })
process.exit(result.status ?? 1)
+35 -1
View File
@@ -3,7 +3,8 @@ import { createRequire } from 'node:module'
import test from 'node:test'
const require = createRequire(import.meta.url)
const { spectrum } = require('../native/build/Release/visualizer_dsp.node')
const nativeAddon = require('../native/build/Release/visualizer_dsp.node')
const { spectrum } = nativeAddon
const SAMPLE_RATE = 48000
const SILENCE_DB = -120
@@ -45,6 +46,39 @@ function interpolatePeakDb(magnitudes) {
return y2 - (0.25 * (y1 - y3) * offset)
}
test('native capture exports preserve the renderer-facing API shape', () => {
for (const exportName of ['macosCapture', 'windowsCapture', 'linuxCapture']) {
const capture = nativeAddon[exportName]
assert.ok(capture, `${exportName} should be exported`)
for (const method of ['getSupport', 'listOutputDevices', 'start', 'stop', 'drain', 'nowMilliseconds']) {
assert.equal(typeof capture[method], 'function', `${exportName}.${method} should be a function`)
}
}
const activeExport = process.platform === 'darwin'
? 'macosCapture'
: process.platform === 'win32'
? 'windowsCapture'
: 'linuxCapture'
const support = nativeAddon[activeExport].getSupport()
assert.equal(typeof support.available, 'boolean')
assert.ok(support.reason === null || typeof support.reason === 'string')
for (const exportName of ['macosCapture', 'windowsCapture', 'linuxCapture']) {
if (exportName === activeExport) continue
const capture = nativeAddon[exportName]
assert.deepEqual(capture.listOutputDevices(), [])
assert.equal(capture.getSupport().available, false)
assert.deepEqual(capture.drain(), { chunks: [], overwriteCount: 0, queueDepth: 0 })
assert.throws(() => capture.start(), /unavailable on the current platform/)
}
assert.ok(nativeAddon.windowsMedia)
for (const method of ['getSupport', 'getSpotifyPlaybackState', 'sendSpotifyControl']) {
assert.equal(typeof nativeAddon.windowsMedia[method], 'function')
}
})
test('spectrum channel-max dBFS is calibrated for bin-centered amplitudes', () => {
const fftSize = 2048
const bin = 42
+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;
}