From 7047df3e3837c7dd0c5994a73ac832da64f900c9 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:37:16 -0400 Subject: [PATCH] prism-tui PoC --- .github/workflows/release.yml | 35 ++- .gitignore | 3 + README.md | 58 +++- THIRD_PARTY_NOTICES.md | 28 ++ electron-builder.appimage.cjs | 10 + native/binding.gyp | 9 +- native/src/linux_capture.cpp | 226 ++++++---------- native/src/linux_capture.h | 5 - native/src/linux_capture_stub.cpp | 54 ---- native/src/macos_capture.h | 5 - native/src/macos_capture.mm | 218 +++++---------- native/src/macos_capture_stub.cpp | 54 ---- native/src/main.cpp | 8 +- native/src/system_audio_capture.h | 62 +++++ native/src/system_audio_capture_napi.cpp | 206 ++++++++++++++ native/src/system_audio_capture_napi.h | 5 + native/src/windows_capture.cpp | 206 ++++++-------- native/src/windows_capture.h | 2 +- native/src/windows_capture_stub.cpp | 49 +--- package.json | 26 +- .../linux/install-vst3-from-package.sh | 46 +++- .../linux/remove-vst3-from-system.sh | 36 +++ resources/installer/macos-scripts/postinstall | 22 +- resources/installer/windows-vst3.nsh | 33 ++- scripts/build/build-tui.cjs | 49 ++++ scripts/build/dist-current.cjs | 17 ++ test/spectrum-native.test.mjs | 36 ++- tui/CMakeLists.txt | 97 +++++++ tui/dist-installer/.gitkeep | 1 + tui/src/analysis_pipeline.cpp | 51 ++++ tui/src/analysis_pipeline.h | 37 +++ tui/src/cli.cpp | 62 +++++ tui/src/cli.h | 29 ++ tui/src/display_model.cpp | 166 ++++++++++++ tui/src/display_model.h | 39 +++ tui/src/main.cpp | 89 +++++++ tui/src/snapshot_store.h | 26 ++ tui/src/tui_runtime.cpp | 252 ++++++++++++++++++ tui/src/tui_runtime.h | 13 + tui/test/cli_exit_tests.cmake | 37 +++ tui/test/tui_tests.cpp | 223 ++++++++++++++++ 41 files changed, 1995 insertions(+), 635 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 electron-builder.appimage.cjs delete mode 100644 native/src/linux_capture.h delete mode 100644 native/src/linux_capture_stub.cpp delete mode 100644 native/src/macos_capture.h delete mode 100644 native/src/macos_capture_stub.cpp create mode 100644 native/src/system_audio_capture.h create mode 100644 native/src/system_audio_capture_napi.cpp create mode 100644 native/src/system_audio_capture_napi.h create mode 100644 scripts/build/build-tui.cjs create mode 100644 scripts/build/dist-current.cjs create mode 100644 tui/CMakeLists.txt create mode 100644 tui/dist-installer/.gitkeep create mode 100644 tui/src/analysis_pipeline.cpp create mode 100644 tui/src/analysis_pipeline.h create mode 100644 tui/src/cli.cpp create mode 100644 tui/src/cli.h create mode 100644 tui/src/display_model.cpp create mode 100644 tui/src/display_model.h create mode 100644 tui/src/main.cpp create mode 100644 tui/src/snapshot_store.h create mode 100644 tui/src/tui_runtime.cpp create mode 100644 tui/src/tui_runtime.h create mode 100644 tui/test/cli_exit_tests.cmake create mode 100644 tui/test/tui_tests.cpp diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d82ee65..76438c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 25def0d..0f62405 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index a43909e..591778f 100644 --- a/README.md +++ b/README.md @@ -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 # 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 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f27793c --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/electron-builder.appimage.cjs b/electron-builder.appimage.cjs new file mode 100644 index 0000000..73a2784 --- /dev/null +++ b/electron-builder.appimage.cjs @@ -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), + ), +} diff --git a/native/binding.gyp b/native/binding.gyp index 2649e6b..123c3a7 100644 --- a/native/binding.gyp +++ b/native/binding.gyp @@ -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" ], diff --git a/native/src/linux_capture.cpp b/native/src/linux_capture.cpp index ea0f7b1..c265082 100644 --- a/native/src/linux_capture.cpp +++ b/native/src/linux_capture.cpp @@ -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 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 listOutputDevices() override { PulseContextConnection connection; std::string errorMessage; std::vector 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(device.sampleSpec.rate))); - entry.Set( - "channelCount", - Napi::Number::New(env, static_cast(device.sampleSpec.channels))); - devicesArray.Set(static_cast(index), entry); + std::vector result; + result.reserve(devices.size()); + for (const auto& device : devices) { + result.push_back({ + device.id, + device.label, + static_cast(device.sampleSpec.rate), + static_cast(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().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 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(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 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(1, info[0].As().Uint32Value()) - : kDefaultDrainChunkLimit; - - std::deque 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 drained; + Prism::Capture::DrainResult result; { std::lock_guard 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(chunk.channelCount))); - entry.Set( - "capturedAtMilliseconds", - Napi::Number::New(env, chunk.capturedAtMilliseconds)); - entry.Set( - "sequence", - Napi::Number::New(env, static_cast(chunk.sequence))); - chunks.Set(static_cast(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(overwriteCount))); - result.Set("queueDepth", Napi::Number::New(env, static_cast(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 createSystemAudioCapture() { + return std::make_unique(); } +} // namespace Prism::Capture + #endif diff --git a/native/src/linux_capture.h b/native/src/linux_capture.h deleted file mode 100644 index 3e38e97..0000000 --- a/native/src/linux_capture.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once - -#include - -void RegisterLinuxCapture(Napi::Env env, Napi::Object exports); diff --git a/native/src/linux_capture_stub.cpp b/native/src/linux_capture_stub.cpp deleted file mode 100644 index 12a0ed2..0000000 --- a/native/src/linux_capture_stub.cpp +++ /dev/null @@ -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); -} diff --git a/native/src/macos_capture.h b/native/src/macos_capture.h deleted file mode 100644 index 4111a56..0000000 --- a/native/src/macos_capture.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once - -#include - -void RegisterMacOSCapture(Napi::Env env, Napi::Object exports); diff --git a/native/src/macos_capture.mm b/native/src/macos_capture.mm index 2666944..f5c6403 100644 --- a/native/src/macos_capture.mm +++ b/native/src/macos_capture.mm @@ -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(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 listOutputDevices() override { + std::vector 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(device.channelCount))); - result.Set(static_cast(index), entry); + result.reserve(devices.size()); + for (const auto& device : devices) { + result.push_back({ + device.uid, + device.label, + device.sampleRate, + static_cast(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().Value()) { - Napi::Error::New( - env, support.Get("reason").As().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 lock(stateMutex_); - result.Set("sampleRate", Napi::Number::New(env, sampleRate_)); - result.Set("channelCount", Napi::Number::New(env, static_cast(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 lock(stateMutex_); + result->sampleRate = sampleRate_; + result->channelCount = static_cast(channelCount_); + result->deviceId = activeDeviceUid_; + result->deviceLabel = activeDeviceLabel_; + } + return true; } - void Stop() { + void stop() override { std::lock_guard 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 drained; - uint64_t overwriteCount = 0; - + Prism::Capture::DrainResult result; { std::lock_guard 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(chunk.channelCount))); - entry.Set( - "capturedAtMilliseconds", - Napi::Number::New(env, chunk.capturedAtMilliseconds)); - entry.Set( - "sequence", - Napi::Number::New(env, static_cast(chunk.sequence))); - chunks.Set(static_cast(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(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(overwriteCount))); - result.Set( - "queueDepth", - Napi::Number::New(env, static_cast(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().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().Int64Value(); - if (requested > 0) { - maxChunks = static_cast(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 createSystemAudioCapture() { + return std::make_unique(); } +} // namespace Prism::Capture + #endif // defined(__APPLE__) diff --git a/native/src/macos_capture_stub.cpp b/native/src/macos_capture_stub.cpp deleted file mode 100644 index d0fe5c8..0000000 --- a/native/src/macos_capture_stub.cpp +++ /dev/null @@ -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); -} diff --git a/native/src/main.cpp b/native/src/main.cpp index 9fe602a..018c288 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -2,8 +2,7 @@ #include #include #include -#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; diff --git a/native/src/system_audio_capture.h b/native/src/system_audio_capture.h new file mode 100644 index 0000000..a1bd75c --- /dev/null +++ b/native/src/system_audio_capture.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include +#include + +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 left; + std::vector right; + uint32_t channelCount = 2; + double capturedAtMilliseconds = 0.0; + uint64_t sequence = 0; +}; + +struct DrainResult { + std::vector chunks; + uint64_t overwriteCount = 0; + size_t queueDepth = 0; +}; + +class SystemAudioCapture { +public: + virtual ~SystemAudioCapture() = default; + + virtual Support getSupport() const = 0; + virtual std::vector 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 createSystemAudioCapture(); + +} // namespace Prism::Capture diff --git a/native/src/system_audio_capture_napi.cpp b/native/src/system_audio_capture_napi.cpp new file mode 100644 index 0000000..c3bcb95 --- /dev/null +++ b/native/src/system_audio_capture_napi.cpp @@ -0,0 +1,206 @@ +#include "system_audio_capture_napi.h" + +#include "system_audio_capture.h" + +#include +#include +#include +#include + +namespace { + +constexpr size_t kDefaultDrainChunkLimit = 64; +constexpr size_t kMaxDrainChunkLimit = 256; + +std::unique_ptr& activeCapture() { + static std::unique_ptr 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(chunk.sequence))); + chunks.Set(static_cast(index), entry); + } + + Napi::Object result = Napi::Object::New(env); + result.Set("chunks", chunks); + result.Set("overwriteCount", Napi::Number::New(env, static_cast(drained.overwriteCount))); + result.Set("queueDepth", Napi::Number::New(env, static_cast(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(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().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().Int64Value(); + if (requested > 0) { + maxChunks = std::min(static_cast(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 +} diff --git a/native/src/system_audio_capture_napi.h b/native/src/system_audio_capture_napi.h new file mode 100644 index 0000000..d9dfa3e --- /dev/null +++ b/native/src/system_audio_capture_napi.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +void RegisterSystemAudioCapture(Napi::Env env, Napi::Object exports); diff --git a/native/src/windows_capture.cpp b/native/src/windows_capture.cpp index 5987527..e30cd34 100644 --- a/native/src/windows_capture.cpp +++ b/native/src/windows_capture.cpp @@ -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 #include #include -#include #include #include +#ifndef PRISM_CAPTURE_CORE_ONLY +#include #include #include #include #include #include +#endif #include #include @@ -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 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 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(device.channelCount))); - result.Set(static_cast(index), entry); + std::vector result; + result.reserve(devices.size()); + for (const auto& device : devices) { + result.push_back({ + device.id, + device.label, + device.sampleRate, + static_cast(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 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(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 lock(stateMutex_); + result->sampleRate = sampleRate_; + result->channelCount = static_cast(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 drained; - uint64_t overwriteCount = 0; - size_t queueDepth = 0; - + Prism::Capture::DrainResult result; { std::lock_guard 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(chunk.channelCount))); - entry.Set( - "capturedAtMilliseconds", - Napi::Number::New(env, chunk.capturedAtMilliseconds)); - entry.Set( - "sequence", - Napi::Number::New(env, static_cast(chunk.sequence))); - chunks.Set(static_cast(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(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(overwriteCount))); - result.Set("queueDepth", Napi::Number::New(env, static_cast(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().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().Int64Value(); - if (requested > 0) { - maxChunks = static_cast(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 createSystemAudioCapture() { + return std::make_unique(); +} + +} // namespace Prism::Capture #endif // defined(_WIN32) diff --git a/native/src/windows_capture.h b/native/src/windows_capture.h index 8c937c6..7bd31fd 100644 --- a/native/src/windows_capture.h +++ b/native/src/windows_capture.h @@ -2,4 +2,4 @@ #include -void RegisterWindowsCapture(Napi::Env env, Napi::Object exports); +void RegisterWindowsMedia(Napi::Env env, Napi::Object exports); diff --git a/native/src/windows_capture_stub.cpp b/native/src/windows_capture_stub.cpp index bdd0405..de22197 100644 --- a/native/src/windows_capture_stub.cpp +++ b/native/src/windows_capture_stub.cpp @@ -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( diff --git a/package.json b/package.json index 65019dc..01d341f 100644 --- a/package.json +++ b/package.json @@ -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" ] } diff --git a/resources/installer/linux/install-vst3-from-package.sh b/resources/installer/linux/install-vst3-from-package.sh index 6ca4171..2d70165 100755 --- a/resources/installer/linux/install-vst3-from-package.sh +++ b/resources/installer/linux/install-vst3-from-package.sh @@ -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 diff --git a/resources/installer/linux/remove-vst3-from-system.sh b/resources/installer/linux/remove-vst3-from-system.sh index e90da2e..c751394 100755 --- a/resources/installer/linux/remove-vst3-from-system.sh +++ b/resources/installer/linux/remove-vst3-from-system.sh @@ -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" diff --git a/resources/installer/macos-scripts/postinstall b/resources/installer/macos-scripts/postinstall index 235bfc6..1e68b2a 100755 --- a/resources/installer/macos-scripts/postinstall +++ b/resources/installer/macos-scripts/postinstall @@ -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 diff --git a/resources/installer/windows-vst3.nsh b/resources/installer/windows-vst3.nsh index f9e290f..0af3e19 100644 --- a/resources/installer/windows-vst3.nsh +++ b/resources/installer/windows-vst3.nsh @@ -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" diff --git a/scripts/build/build-tui.cjs b/scripts/build/build-tui.cjs new file mode 100644 index 0000000..b6c80de --- /dev/null +++ b/scripts/build/build-tui.cjs @@ -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}`) +} diff --git a/scripts/build/dist-current.cjs b/scripts/build/dist-current.cjs new file mode 100644 index 0000000..c805cb0 --- /dev/null +++ b/scripts/build/dist-current.cjs @@ -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) diff --git a/test/spectrum-native.test.mjs b/test/spectrum-native.test.mjs index eba16d4..ed27c6d 100644 --- a/test/spectrum-native.test.mjs +++ b/test/spectrum-native.test.mjs @@ -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 diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt new file mode 100644 index 0000000..e2394bf --- /dev/null +++ b/tui/CMakeLists.txt @@ -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=$ + -DEXPECTED_VERSION=${PRISM_VERSION} + -P ${CMAKE_CURRENT_SOURCE_DIR}/test/cli_exit_tests.cmake) +endif() diff --git a/tui/dist-installer/.gitkeep b/tui/dist-installer/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tui/dist-installer/.gitkeep @@ -0,0 +1 @@ + diff --git a/tui/src/analysis_pipeline.cpp b/tui/src/analysis_pipeline.cpp new file mode 100644 index 0000000..c1372cb --- /dev/null +++ b/tui/src/analysis_pipeline.cpp @@ -0,0 +1,51 @@ +#include "analysis_pipeline.h" + +#include + +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 diff --git a/tui/src/analysis_pipeline.h b/tui/src/analysis_pipeline.h new file mode 100644 index 0000000..aff014d --- /dev/null +++ b/tui/src/analysis_pipeline.h @@ -0,0 +1,37 @@ +#pragma once + +#include "lufsmeter.h" +#include "spectrum.h" +#include "system_audio_capture.h" +#include "vumeter.h" + +#include + +namespace Prism::Tui { + +struct AnalysisFrame { + std::vector 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 diff --git a/tui/src/cli.cpp b/tui/src/cli.cpp new file mode 100644 index 0000000..662ec0b --- /dev/null +++ b/tui/src/cli.cpp @@ -0,0 +1,62 @@ +#include "cli.h" + +namespace Prism::Tui { + +ParseResult parseArguments(const std::vector& 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 ]\n" + " prism-tui --list-devices\n" + " prism-tui --help\n" + " prism-tui --version\n\n" + "Options:\n" + " --device 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 diff --git a/tui/src/cli.h b/tui/src/cli.h new file mode 100644 index 0000000..0dd179b --- /dev/null +++ b/tui/src/cli.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +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& arguments); +std::string usageText(); + +} // namespace Prism::Tui diff --git a/tui/src/display_model.cpp b/tui/src/display_model.cpp new file mode 100644 index 0000000..978175d --- /dev/null +++ b/tui/src/display_model.cpp @@ -0,0 +1,166 @@ +#include "display_model.h" + +#include +#include +#include +#include +#include + +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(std::max(8, width - 4)); + model.spectrumRowCount = static_cast(std::max(2, height - 10)); + model.meterWidth = static_cast(std::max(8, width - 19)); + return model; +} + +std::vector projectSpectrum(const std::vector& 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(fftSize); + const float dbSpan = std::max(1.0f, options.maxDecibels - options.minDecibels); + + std::vector projected(columns, 0.0f); + for (size_t column = 0; column < columns; ++column) { + const float leftPosition = static_cast(column) / static_cast(columns); + const float rightPosition = static_cast(column + 1) / static_cast(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(std::floor(leftFrequency / binWidth))); + const size_t lastBin = std::min( + magnitudes.size() - 1, + std::max(firstBin, static_cast(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 buildSpectrumRows(const std::vector& normalized, + size_t rowCount) { + if (normalized.empty() || rowCount == 0) { + return {}; + } + + static const char* partialBlocks[] = {" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇"}; + std::vector rows(rowCount); + const int totalUnits = static_cast(rowCount * 8); + for (size_t row = 0; row < rowCount; ++row) { + std::string line; + const int rowBottom = static_cast((rowCount - row - 1) * 8); + for (float value : normalized) { + const int filled = static_cast(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(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(std::round(normalized * static_cast(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 diff --git a/tui/src/display_model.h b/tui/src/display_model.h new file mode 100644 index 0000000..653e8d0 --- /dev/null +++ b/tui/src/display_model.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +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 projectSpectrum(const std::vector& magnitudes, + size_t fftSize, + size_t columns, + const SpectrumProjectionOptions& options); + +std::vector buildSpectrumRows(const std::vector& 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 diff --git a/tui/src/main.cpp b/tui/src/main.cpp new file mode 100644 index 0000000..8963244 --- /dev/null +++ b/tui/src/main.cpp @@ -0,0 +1,89 @@ +#include "cli.h" +#include "system_audio_capture.h" +#include "tui_runtime.h" + +#include +#include +#include +#include +#include + +#ifndef PRISM_VERSION +#define PRISM_VERSION "development" +#endif + +namespace { + +int run(const std::vector& 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(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 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; + } +} diff --git a/tui/src/snapshot_store.h b/tui/src/snapshot_store.h new file mode 100644 index 0000000..acf75cc --- /dev/null +++ b/tui/src/snapshot_store.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +namespace Prism::Tui { + +template +class SnapshotStore { +public: + void publish(T next) { + std::lock_guard lock(mutex_); + snapshot_ = std::move(next); + } + + T read() const { + std::lock_guard lock(mutex_); + return snapshot_; + } + +private: + mutable std::mutex mutex_; + T snapshot_{}; +}; + +} // namespace Prism::Tui diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp new file mode 100644 index 0000000..17bff53 --- /dev/null +++ b/tui/src/tui_runtime.cpp @@ -0,0 +1,252 @@ +#include "tui_runtime.h" + +#include "analysis_pipeline.h" +#include "display_model.h" +#include "snapshot_store.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#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 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(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 capture, + const Prism::Capture::StartResult& started) { + using namespace ftxui; + signalRequested = 0; + SignalHandlerGuard signalHandlerGuard; + + ScreenInteractive screen = ScreenInteractive::Fullscreen(); + SnapshotStore 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 running{true}; + std::atomic resetRequested{false}; + std::exception_ptr workerError; + auto exitLoop = screen.ExitLoopClosure(); + + std::thread worker([&]() { + try { + AnalysisPipeline pipeline(static_cast(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 diff --git a/tui/src/tui_runtime.h b/tui/src/tui_runtime.h new file mode 100644 index 0000000..f1a5a89 --- /dev/null +++ b/tui/src/tui_runtime.h @@ -0,0 +1,13 @@ +#pragma once + +#include "system_audio_capture.h" + +#include + +namespace Prism::Tui { + +bool stdinAndStdoutAreTerminals(); +int runInteractive(std::unique_ptr capture, + const Prism::Capture::StartResult& started); + +} // namespace Prism::Tui diff --git a/tui/test/cli_exit_tests.cmake b/tui/test/cli_exit_tests.cmake new file mode 100644 index 0000000..d942866 --- /dev/null +++ b/tui/test/cli_exit_tests.cmake @@ -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() diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp new file mode 100644 index 0000000..da6e6c1 --- /dev/null +++ b/tui/test/tui_tests.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +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(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(index) / sampleRate); + } + return chunk; +} + +class FakeCapture final : public Prism::Capture::SystemAudioCapture { +public: + Prism::Capture::Support getSupport() const override { return {true, {}}; } + std::vector 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 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(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(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 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; +}