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