37 Commits

Author SHA1 Message Date
Boof2015 7cb7f6c126 safe compilation 2026-06-03 10:19:21 -04:00
Boof2015 9342dc51f2 Revert "update JUCE"
This reverts commit 67f1c46862.
2026-06-03 10:10:07 -04:00
Boof2015 33b39626aa add logging 2026-06-03 09:15:18 -04:00
Boof2015 67f1c46862 update JUCE 2026-06-03 08:34:55 -04:00
Boof2015 2d634fda17 spot fix 2026-06-02 17:56:34 -04:00
Boof2015 6ff11aa6bd fixes 2026-06-02 17:24:48 -04:00
Boof2015 9e9569fcac fix crash in linux vst 2026-06-02 17:04:30 -04:00
Boof2015 54fcefb9de more render issue fixes for linux vst 2026-06-02 16:47:23 -04:00
Boof2015 f5f406b43c fix render issue for linux vst 2026-06-02 16:12:51 -04:00
Boof2015 583b436600 tests for linux vsts 2026-06-02 14:46:30 -04:00
Boof2015 6ebafcc942 fix vst crash on linux 2026-06-02 13:50:01 -04:00
Boof2015 73ade9074d cmake error on ubuntu ci 2026-06-02 12:57:22 -04:00
Boof2015 f38fe4fa29 fix ubuntu ci error 2026-06-02 12:50:26 -04:00
Boof2015 217c753eb8 initial support for linux vst 2026-06-02 12:24:42 -04:00
Boof2015 e7cc6f12fd improve vst refresh rate on windows 2026-06-02 10:46:35 -04:00
Boof2015 f25b0b671d fix vst error on windows 2026-06-02 09:56:08 -04:00
Boof2015 9e7e20606e fix vsts not loading on windows 2026-06-02 09:22:23 -04:00
Boof2015 f3131e365b stop treating warnings as errors in nsis uninstaller 2026-06-02 08:47:18 -04:00
Boof2015 6dc0e43914 improve nsis 2026-06-02 08:34:13 -04:00
Boof2015 593d0e5ee8 update windows installer 2026-06-02 08:11:54 -04:00
Boof2015 f9fa9b8f4f fix postinstall error in pkg 2026-06-01 20:09:21 -04:00
Boof2015 aa4672d258 speed up cmake 2026-06-01 19:37:51 -04:00
Boof2015 32d5c91092 fix cmake error 2026-06-01 19:14:44 -04:00
Boof2015 6f65b69ad8 fix another another ci error 2026-06-01 19:02:51 -04:00
Boof2015 3f74a74d65 fix another ci error 2026-06-01 18:50:51 -04:00
Boof2015 586906c8e0 fix ci error 2026-06-01 18:46:49 -04:00
Boof2015 5fe4a45df4 new workflow, add windows support for vst, switch to pkg 2026-06-01 18:27:33 -04:00
Boof2015 281f84ec46 improve UX 2026-05-28 02:19:29 -04:00
Boof2015 c9d741c979 fix performance issues 2026-05-28 00:19:51 -04:00
Boof2015 8006764608 waveform VST 2026-05-27 23:44:30 -04:00
Boof2015 4de676a8ad spectrogram VST 2026-05-27 19:56:09 -04:00
Boof2015 7da5f62c5e vectorscope VST 2026-05-27 19:22:22 -04:00
Boof2015 434b65605a LUFS VST 2026-05-27 19:04:44 -04:00
Boof2015 ddae07c985 VU VST 2026-05-27 18:55:41 -04:00
Boof2015 b877541882 oscilloscope VST 2026-05-27 14:28:20 -04:00
Boof2015 60949a9389 improvements to the VST 2026-05-27 13:37:01 -04:00
Boof2015 3335b4a917 initial VST proof of concept 2026-05-26 16:12:53 -04:00
185 changed files with 1746 additions and 20496 deletions
+8 -33
View File
@@ -115,23 +115,15 @@ jobs:
fi
echo "Native module built successfully: $(ls -lh native/build/Release/visualizer_dsp.node)"
- name: Typecheck
run: npm run typecheck
# 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: Test native spectrum and capture exports
run: npm run test:spectrum-native
- name: Test native spectrogram accuracy
run: npm run test:spectrogram-native
- name: Test native vectorscope calibration
run: npm run test:vectorscope-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
@@ -258,23 +250,6 @@ jobs:
fi
fi
- name: Stage Prism TUI for installer
run: npm run stage:tui
- name: Smoke-test staged Prism TUI
shell: bash
run: |
tui_binary="tui/dist-installer/prism-tui"
if [ "$RUNNER_OS" = "Windows" ]; then
tui_binary="tui/dist-installer/prism-tui.exe"
fi
if [ ! -f "$tui_binary" ]; then
echo "ERROR: staged prism-tui executable was not found: $tui_binary"
exit 1
fi
"$tui_binary" --help
"$tui_binary" --version
- name: Build distributable
if: runner.os != 'Linux'
run: npm run ${{ matrix.dist_cmd }}
-3
View File
@@ -5,9 +5,6 @@ node_modules/
out/
dist/
native/build/
tui/build/
tui/dist-installer/prism-tui
tui/dist-installer/prism-tui.exe
# JUCE plugin build + installer staging
plugin/build/
+50 -107
View File
@@ -1,127 +1,65 @@
<div align="center">
# Prism
<img src="assets/prism-badge.png" alt="Prism" width="440">
**Real-time audio analysis and metering, wherever you work.**
A free, open-source audio visualizer and meter rack for your desktop.
![code size](https://img.shields.io/github/languages/code-size/Boof2015/prism)
![GitHub Release](https://img.shields.io/github/v/release/Boof2015/prism?include_prereleases)
![GitHub License](https://img.shields.io/github/license/Boof2015/prism)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Boof2015/prism/main.yml)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/Boof2015/prism/total)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/boof2015/prism/total)
</div>
Prism is a free, open-source audio analyzer and meter rack for Windows, macOS, and Linux.
Monitor system audio or an input with a configurable set of real-time scopes and meters, arrange them however you like, and save the setup as a profile. The same analyzers are also available as VST3/AU plugins, and Prism includes a native terminal interface for lightweight monitoring.
![Prism scopes](assets/prism-demo-readme-optimized.gif)
Prism taps into your system audio and runs it through a rack of real-time scopes and meters. It grew out of the visualization engine in [Astra](https://github.com/Boof2015/astra), rebuilt as a standalone tool. Whether you're mixing a track, tuning a room, or just like watching your music, Prism gives you a window into what you're hearing.
## Scopes
Prism includes seven scopes and meters:
Seven visualizers driven by a native C++ analysis engine:
* **Spectrum Analyzer** — Frequency spectrum with heatmap and fill modes, configurable FFT size, spectral tilt, multiple frequency scales, and peak information
* **Oscilloscope** — Time-domain waveform with an optional pitch-lock mode that follows the fundamental frequency
* **Vectorscope** — Stereo image and phase visualization with XY, Polar, and M/S Linear views, adjustable zoom, and an optional multiband RGB split
* **Spectrogram** — Scrolling frequency-over-time display with Log, Mel, and Linear scales and several detail modes
* **VU Meter** — Classic VU metering with needle and bar styles in horizontal or vertical layouts
* **Loudness Meter** — LUFS metering following ITU-R BS.1770 alongside stereo peak activity
* **Waveform** — Scrolling waveform view with mono, stereo, and multiband modes
- **Spectrum Analyzer** — FFT frequency display with heatmap and fill modes, configurable FFT size, spectral tilt, and log or linear scaling
- **Oscilloscope** — Time-domain waveform with a pitch-lock mode that syncs the display to the fundamental frequency
- **Vectorscope** — Stereo phase visualization in five display modes (Lissajous, polar, linear) with optional multiband RGB split
- **Spectrogram** — Scrolling frequency-over-time display with mel, log, and linear scale modes
- **VU Meter** — Classic loudness metering in needle or bar style, horizontal or vertical
- **Loudness Meter** — Compact LUFS metering following ITU-R BS.1770 with fast stereo peak activity
- **Waveform** — Scrolling time-domain view with mono, stereo, and multiband modes
Every scope can be configured independently. Resize and rearrange them into a rack, rotate them, pop individual scopes into their own windows, or pin them on top of other applications.
![Prism scopes](assets/prism-demo-12fps.gif)
Compatible scopes also include an interactive measurement overlay for inspecting the display directly.
Every scope is independently configurable. Drag and resize them into whatever layout makes sense, pop any scope out into its own window, and pin windows on top so they stay visible while you work.
## Desktop
## Audio Capture
Prism can capture system output directly through CoreAudio on macOS, WASAPI on Windows, and PulseAudio on Linux. It can also switch to an input device when you want to analyze a microphone, interface, or line input.
Prism pulls audio at the OS level, straight from CoreAudio on macOS, WASAPI on Windows, or PulseAudio on Linux. No virtual cables or routing hacks. Flip on device input mode when you want to analyze a mic or a line-in instead.
No virtual audio cable is required for normal system capture.
The main rack and scope popouts can use solid, blurred, or clear backgrounds, making Prism usable as a normal desktop application or as a set of unobtrusive overlays.
Prism can also live in the system tray, start automatically with your computer, and launch either normally or out of the way when you want it running all the time.
## Rolling Capture
Prism can continuously keep the last **5, 10, 30, or 60 seconds** of audio in memory.
When you hear something you want to keep, drag the buffered audio out of Prism as a WAV file. There is no need to start recording beforehand.
## DAW Plugins
Every Prism scope is also available as a DAW plugin.
Drop a **Spectrum**, **Oscilloscope**, **Vectorscope**, **Spectrogram**, **VU Meter**, **Loudness Meter**, or **Waveform** onto a track and analyze it using the same interface and analysis engine as the desktop app.
* **VST3** on Windows, macOS, and Linux
* **AU** on macOS
* Settings are stored with your project
* Plugins follow your Prism themes and profiles
* Audio passes through untouched
Tested in Ableton Live, FL Studio, and Reaper.
The plugins install alongside Prism, so there is no separate download.
## Terminal UI
Prism also includes `prism-tui`, a native terminal interface built on the same capture and analysis code.
![Prism TUI](assets/prism-tui-demo-readme.gif)
It provides real-time spectrum, metering, loudness, and other monitoring tools without needing to run the desktop interface.
```bash
prism-tui
prism-tui --list-outputs
prism-tui --output <id>
prism-tui --profile <name>
prism-tui --theme <name>
prism-tui --help
```
Profiles and `.iro` themes can be used from the TUI as well, and output devices can be changed without leaving it.
`prism-tui --help` lists the available controls and launch options.
Capture-to-display latency measures under 8ms. When tested at 120fps, measured latency was 0ms. What you see is what you hear.
## Profiles
A profile stores your Prism workspace: which scopes are visible, how they are laid out, their individual settings, and window state.
Keep different profiles for different setups or workflows and switch between them when needed.
Profiles are stored as `.prsm` files and can be shared with other Prism users.
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.
## Themes
Prism's interface is themeable through editable `.iro` files.
Themes control the colors used throughout the application and its scopes. You can edit your own, drop them into the `Prism Themes` folder, or download themes made by the community.
The whole interface is themeable through editable `.iro` files. Themes control everything, build your own in an editor and drop it into the `Prism Themes` folder, or download one from [the community](https://discord.gg/hsKK8Kr9Nj).
![Prism themes](assets/themes.png)
Prism also includes chroma-key-friendly themes for using scopes as overlays in OBS or other video software.
![Prism chroma themes](assets/chroma.png)
Prism also includes Chroma key friendly themes by default, so you can key out your scopes in OBS and drop them directly into your stream as transparent overlays.
![Prism themes](assets/chroma.png)
## Download
Prebuilt versions of Prism for Windows, macOS, and Linux are available on the [Releases](https://github.com/Boof2015/prism/releases) page.
Installable packages include the desktop application and the components supported by that package, including DAW plugins and `prism-tui` where applicable.
Prebuilt binaries for Windows, macOS, and Linux are available on the [Releases](https://github.com/Boof2015/prism/releases) page.
## Building from Source
**Prerequisites:** Node.js 18+, npm, CMake 3.22+, and a C++ compiler toolchain.
**Prerequisites:** Node.js 18+, npm, and a C++ compiler toolchain.
| Platform | Toolchain |
| -------- | ------------------------------------------------------------------------------------------------------- |
| macOS | Xcode Command Line Tools |
| Windows | Visual Studio Build Tools |
| Linux | `build-essential`, `python3`, `libasound2-dev`, `libpulse-dev`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` |
| Platform | Toolchain |
|----------|-----------|
| macOS | Xcode Command Line Tools |
| Windows | Visual Studio Build Tools |
| Linux | `build-essential`, `python3`, `libasound2-dev`, `libpulse-dev`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` |
```bash
git clone https://github.com/Boof2015/prism.git
@@ -134,33 +72,38 @@ The `postinstall` script compiles the native C++ module for your platform.
```bash
npm run dev # Development
npm run build # Build application assets
npm run configure:tui # Configure the standalone CMake project
npm run build:tui # Build prism-tui
npm run test:tui # Build and run native TUI tests
npm run dist # Package for current platform
npm run dist:mac # macOS
npm run dist:win # Windows
npm run dist:linux # Linux
npm run dist:mac # macOS (DMG + ZIP)
npm run dist:win # Windows (NSIS + Portable)
npm run dist:linux # Linux (AppImage + DEB + RPM + tar.gz)
```
The TUI build downloads the pinned FTXUI source through CMake. Linux also requires the PulseAudio development package.
The DAW plugins build with CMake from the [`plugin/`](plugin/) directory. See [`plugin/README.md`](plugin/README.md) for per-platform build and installation details.
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`.
## Astra Integration
Prism can optionally connect to [Astra](https://github.com/Boof2015/astra) through its local API to show currently playing music, cover art, track information, and playback controls alongside your scopes.
Prism does not require Astra and works as a standalone application.
If you use [Astra](https://github.com/Boof2015/astra), Prism can connect to its local API to show what's playing, cover art, track info, and playback controls, alongside your scopes.
## Support
If you find Prism useful and want to support development:
If you find Prism useful and want to support a broke college student, consider supporting development:
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=ko-fi\&logoColor=white)](https://ko-fi.com/boof2015)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/boof2015)
## License
Prism 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.
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 are recorded in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
## Star History
<a href="https://www.star-history.com/#Boof2015/prism&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Boof2015/prism&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Boof2015/prism&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Boof2015/prism&type=date&legend=top-left" />
</picture>
</a>
-28
View File
@@ -1,28 +0,0 @@
# 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

-10
View File
@@ -1,10 +0,0 @@
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),
),
}
+7 -6
View File
@@ -7,7 +7,6 @@
"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",
@@ -16,7 +15,6 @@
"src/waveform.cpp",
"src/vumeter.cpp",
"src/lufsmeter.cpp",
"src/window_chrome.cpp",
"src/dsp_utils.cpp"
],
"include_dirs": [
@@ -28,7 +26,8 @@
["OS=='mac'", {
"sources": [
"src/macos_capture.mm",
"src/windows_capture_stub.cpp"
"src/windows_capture_stub.cpp",
"src/linux_capture_stub.cpp"
],
"xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
@@ -44,7 +43,9 @@
}],
["OS=='win'", {
"sources": [
"src/windows_capture.cpp"
"src/macos_capture_stub.cpp",
"src/windows_capture.cpp",
"src/linux_capture_stub.cpp"
],
"defines": [
"WIN32_LEAN_AND_MEAN",
@@ -54,8 +55,7 @@
"ole32.lib",
"avrt.lib",
"runtimeobject.lib",
"uuid.lib",
"dwmapi.lib"
"uuid.lib"
],
"msvs_settings": {
"VCCLCompilerTool": {
@@ -66,6 +66,7 @@
}],
["OS=='linux'", {
"sources": [
"src/macos_capture_stub.cpp",
"src/windows_capture_stub.cpp",
"src/linux_capture.cpp"
],
+150 -78
View File
@@ -1,4 +1,4 @@
#include "system_audio_capture.h"
#include "linux_capture.h"
#if defined(__linux__)
@@ -406,111 +406,150 @@ private:
bool started_ = false;
};
class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
class LinuxNativeCaptureEngine {
public:
~LinuxNativeCaptureEngine() override {
stop();
}
Napi::Value GetSupport(const Napi::CallbackInfo& info) const {
Napi::Object support = Napi::Object::New(info.Env());
Prism::Capture::Support getSupport() const override {
PulseContextConnection connection;
std::string errorMessage;
std::vector<OutputDeviceInfo> devices;
const bool available =
connection.connect("Prism Linux Capture Probe", &errorMessage) &&
connection.enumerateOutputDevices(&devices, &errorMessage);
return {
available,
available ? std::string() : (errorMessage.empty()
support.Set("available", Napi::Boolean::New(info.Env(), available));
if (available) {
support.Set("reason", info.Env().Null());
} else {
const std::string reason = errorMessage.empty()
? "Native Linux capture is unavailable."
: errorMessage),
};
: errorMessage;
support.Set("reason", Napi::String::New(info.Env(), reason));
}
return support;
}
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) const {
Napi::Env env = info.Env();
Napi::Array devicesArray = Napi::Array::New(env);
PulseContextConnection connection;
std::string errorMessage;
std::vector<OutputDeviceInfo> devices;
if (!connection.connect("Prism Linux Capture Devices", &errorMessage) ||
!connection.enumerateOutputDevices(&devices, &errorMessage)) {
return {};
return devicesArray;
}
std::vector<Prism::Capture::OutputDevice> result;
result.reserve(devices.size());
for (const auto& device : devices) {
result.push_back({
device.id,
device.label,
static_cast<double>(device.sampleSpec.rate),
static_cast<uint32_t>(device.sampleSpec.channels),
device.isDefault,
});
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set(
"sampleRate",
Napi::Number::New(env, static_cast<double>(device.sampleSpec.rate)));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.sampleSpec.channels)));
devicesArray.Set(static_cast<uint32_t>(index), entry);
}
return devicesArray;
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string requestedDeviceId;
if (info.Length() > 0 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
std::string errorMessage;
if (!startInternal(requestedDeviceId, &errorMessage)) {
const std::string reason = errorMessage.empty()
? "Native Linux monitor capture failed to start."
: errorMessage;
Napi::Error::New(env, reason)
.ThrowAsJavaScriptException();
return env.Null();
}
std::lock_guard<std::mutex> lock(stateMutex_);
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set("channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceId_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
}
bool start(const std::string& requestedDeviceId,
Prism::Capture::StartResult* result,
std::string* errorMessage) override {
if (!startInternal(requestedDeviceId, errorMessage)) {
if (errorMessage != nullptr && errorMessage->empty()) {
*errorMessage = "Native Linux monitor capture failed to start.";
}
return false;
}
if (result != nullptr) {
std::lock_guard<std::mutex> lock(stateMutex_);
result->sampleRate = sampleRate_;
result->channelCount = channelCount_;
result->deviceId = activeDeviceId_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
}
void stop() override {
Napi::Value Stop(const Napi::CallbackInfo& info) {
stopInternal();
return info.Env().Undefined();
}
Prism::Capture::DrainResult drain(size_t maxChunks) override {
const size_t drainLimit = maxChunks == 0
? kDefaultDrainChunkLimit
: std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
Prism::Capture::DrainResult result;
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const size_t maxChunks = info.Length() > 0 && info[0].IsNumber()
? std::max<size_t>(1, info[0].As<Napi::Number>().Uint32Value())
: kDefaultDrainChunkLimit;
std::deque<CapturedChunk> drainedChunks;
size_t overwriteCount = 0;
size_t queueDepth = 0;
{
std::lock_guard<std::mutex> lock(chunkMutex_);
const size_t count = std::min(drainLimit, chunkQueue_.size());
for (size_t index = 0; index < count; ++index) {
drained.push_back(std::move(chunkQueue_.front()));
const size_t chunkCount = std::min(maxChunks, chunkQueue_.size());
for (size_t index = 0; index < chunkCount; ++index) {
drainedChunks.push_back(std::move(chunkQueue_.front()));
chunkQueue_.pop_front();
}
result.overwriteCount = overwriteCount_;
overwriteCount = overwriteCount_;
overwriteCount_ = 0;
result.queueDepth = chunkQueue_.size();
queueDepth = chunkQueue_.size();
}
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::Array chunks = Napi::Array::New(env, drainedChunks.size());
for (size_t index = 0; index < drainedChunks.size(); ++index) {
const auto& chunk = drainedChunks[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount", Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(queueDepth)));
return result;
}
double nowMilliseconds() const override {
return monotonicMilliseconds();
}
const char* backendName() const override {
return "PulseAudio";
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) const {
return Napi::Number::New(info.Env(), monotonicMilliseconds());
}
private:
@@ -846,14 +885,47 @@ private:
size_t overwriteCount_ = 0;
};
} // namespace
namespace Prism::Capture {
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
return std::make_unique<LinuxNativeCaptureEngine>();
LinuxNativeCaptureEngine& GetLinuxNativeCaptureEngine() {
static LinuxNativeCaptureEngine engine;
return engine;
}
} // namespace Prism::Capture
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);
}
#endif
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <napi.h>
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports);
+54
View File
@@ -0,0 +1,54 @@
#include "linux_capture.h"
namespace {
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(
info.Env(), "Native Linux output-device capture is unavailable on this platform."));
return support;
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "Native Linux output-device capture is unavailable on this platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
} // namespace
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, Start));
captureExports.Set("stop", Napi::Function::New(env, Stop));
captureExports.Set("drain", Napi::Function::New(env, Drain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set("linuxCapture", captureExports);
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <napi.h>
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports);
+148 -66
View File
@@ -1,4 +1,4 @@
#include "system_audio_capture.h"
#include "macos_capture.h"
#if defined(__APPLE__)
@@ -319,104 +319,140 @@ std::string formatStatusMessage(const char* operation, OSStatus status) {
return std::string(operation) + " failed (" + std::to_string(static_cast<int>(status)) + ")";
}
class MacOSNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
class MacOSNativeCaptureEngine {
public:
~MacOSNativeCaptureEngine() override {
stop();
}
Napi::Object GetSupport(Napi::Env env) {
Napi::Object support = Napi::Object::New(env);
Prism::Capture::Support getSupport() const override {
if (@available(macOS 14.2, *)) {
return {true, {}};
support.Set("available", Napi::Boolean::New(env, true));
support.Set("reason", env.Null());
return support;
}
return {false, "Native output-device capture requires macOS 14.2 or newer."};
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;
}
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
std::vector<Prism::Capture::OutputDevice> result;
Napi::Array ListOutputDevices(Napi::Env env) {
Napi::Array result = Napi::Array::New(env);
if (!isSupported()) {
return result;
}
const auto devices = enumerateOutputDevices();
result.reserve(devices.size());
for (const auto& device : devices) {
result.push_back({
device.uid,
device.label,
device.sampleRate,
static_cast<uint32_t>(device.channelCount),
device.isDefault,
});
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.uid));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.channelCount)));
result.Set(static_cast<uint32_t>(index), entry);
}
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;
Napi::Object Start(Napi::Env env, const std::string& requestedDeviceUid) {
Napi::Object result = Napi::Object::New(env);
const Napi::Object support = GetSupport(env);
if (!support.Get("available").As<Napi::Boolean>().Value()) {
Napi::Error::New(
env, support.Get("reason").As<Napi::String>().Utf8Value())
.ThrowAsJavaScriptException();
return result;
}
if (!startInternal(requestedDeviceId, errorMessage)) {
return false;
std::string errorMessage;
if (!startInternal(requestedDeviceUid, &errorMessage)) {
Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
return result;
}
if (result != nullptr) {
std::lock_guard<std::mutex> lock(stateMutex_);
result->sampleRate = sampleRate_;
result->channelCount = static_cast<uint32_t>(channelCount_);
result->deviceId = activeDeviceUid_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
std::lock_guard<std::mutex> lock(stateMutex_);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set("channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceUid_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
}
void stop() override {
void Stop() {
std::lock_guard<std::mutex> lock(stateMutex_);
stopLocked();
}
Prism::Capture::DrainResult drain(size_t maxChunks) override {
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
const size_t drainLimit =
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
Prism::Capture::DrainResult result;
uint64_t overwriteCount = 0;
{
std::lock_guard<std::mutex> queueLock(chunkMutex_);
result.overwriteCount = overwriteCount_;
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();
}
result.chunks.reserve(drained.size());
while (!drained.empty()) {
auto chunk = std::move(drained.front());
drained.pop_front();
result.chunks.push_back({
std::move(chunk.left),
std::move(chunk.right),
static_cast<uint32_t>(chunk.channelCount),
chunk.capturedAtMilliseconds,
chunk.sequence,
});
Napi::Array chunks = Napi::Array::New(env, drained.size());
for (size_t index = 0; index < drained.size(); ++index) {
CapturedChunk& chunk = drained[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left =
Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right =
Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(
left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(
right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount",
Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set(
"queueDepth",
Napi::Number::New(env, static_cast<double>(chunkQueue_.size())));
return result;
}
double nowMilliseconds() const override {
double NowMilliseconds() const {
return monotonicMilliseconds();
}
const char* backendName() const override {
return "CoreAudio";
}
private:
static OSStatus StaticIOProc(AudioObjectID inDevice,
const AudioTimeStamp* inNow,
@@ -744,14 +780,60 @@ private:
UInt32 channelCount_ = 2;
};
} // namespace
namespace Prism::Capture {
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
return std::make_unique<MacOSNativeCaptureEngine>();
MacOSNativeCaptureEngine& engine() {
static MacOSNativeCaptureEngine instance;
return instance;
}
} // namespace Prism::Capture
Napi::Value MacOSGetSupport(const Napi::CallbackInfo& info) {
return engine().GetSupport(info.Env());
}
Napi::Value MacOSListOutputDevices(const Napi::CallbackInfo& info) {
return engine().ListOutputDevices(info.Env());
}
Napi::Value MacOSStart(const Napi::CallbackInfo& info) {
std::string requestedDeviceUid;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceUid = info[0].As<Napi::String>().Utf8Value();
}
return engine().Start(info.Env(), requestedDeviceUid);
}
Napi::Value MacOSStop(const Napi::CallbackInfo& info) {
engine().Stop();
return info.Env().Undefined();
}
Napi::Value MacOSDrain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = static_cast<size_t>(requested);
}
}
return engine().Drain(info.Env(), maxChunks);
}
Napi::Value MacOSNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
}
} // namespace
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, MacOSGetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, MacOSListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, MacOSStart));
captureExports.Set("stop", Napi::Function::New(env, MacOSStop));
captureExports.Set("drain", Napi::Function::New(env, MacOSDrain));
captureExports.Set(
"nowMilliseconds", Napi::Function::New(env, MacOSNowMilliseconds));
exports.Set("macosCapture", captureExports);
}
#endif // defined(__APPLE__)
+54
View File
@@ -0,0 +1,54 @@
#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);
}
+18 -65
View File
@@ -2,7 +2,8 @@
#include <algorithm>
#include <cstring>
#include <string>
#include "system_audio_capture_napi.h"
#include "linux_capture.h"
#include "macos_capture.h"
#include "windows_capture.h"
#include "oscilloscope.h"
#include "spectrum.h"
@@ -11,7 +12,6 @@
#include "waveform.h"
#include "vumeter.h"
#include "lufsmeter.h"
#include "window_chrome.h"
// Global instances
static Visualizer::Oscilloscope oscilloscope;
@@ -211,14 +211,6 @@ Napi::Value SpectrumGetSideMagnitudes(const Napi::CallbackInfo& info) {
return result;
}
Napi::Value SpectrumGetChannelMaxMagnitudes(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const auto& magnitudes = spectrum.getChannelMaxMagnitudes();
Napi::Float32Array result = Napi::Float32Array::New(env, magnitudes.size());
memcpy(result.Data(), magnitudes.data(), magnitudes.size() * sizeof(float));
return result;
}
Napi::Value SpectrumFillRawMagnitudes(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsTypedArray()) {
@@ -267,22 +259,6 @@ Napi::Value SpectrumFillSideMagnitudes(const Napi::CallbackInfo& info) {
return Napi::Number::New(env, static_cast<double>(count));
}
Napi::Value SpectrumFillChannelMaxMagnitudes(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsTypedArray()) {
Napi::TypeError::New(env, "Expected output Float32Array").ThrowAsJavaScriptException();
return env.Null();
}
Napi::Float32Array output = info[0].As<Napi::Float32Array>();
const auto& magnitudes = spectrum.getChannelMaxMagnitudes();
const size_t count = std::min(output.ElementLength(), magnitudes.size());
if (count > 0) {
memcpy(output.Data(), magnitudes.data(), count * sizeof(float));
}
return Napi::Number::New(env, static_cast<double>(count));
}
Napi::Value SpectrumProcess(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsTypedArray()) {
@@ -327,27 +303,6 @@ std::string GetObjectString(const Napi::Object& obj, const char* key, const std:
Napi::Value value = obj.Get(key);
return value.IsString() ? value.As<Napi::String>().Utf8Value() : fallback;
}
Napi::Object SpectrogramResultToJs(
Napi::Env env,
const Visualizer::SpectrogramProcessResult& result
) {
Napi::Float32Array display = Napi::Float32Array::New(env, result.display.size());
Napi::Float32Array heat = Napi::Float32Array::New(env, result.heat.size());
if (!result.display.empty()) {
memcpy(display.Data(), result.display.data(), result.display.size() * sizeof(float));
}
if (!result.heat.empty()) {
memcpy(heat.Data(), result.heat.data(), result.heat.size() * sizeof(float));
}
Napi::Object obj = Napi::Object::New(env);
obj.Set("display", display);
obj.Set("heat", heat);
obj.Set("columnCount", Napi::Number::New(env, static_cast<double>(result.columnCount)));
obj.Set("rowCount", Napi::Number::New(env, static_cast<double>(result.rowCount)));
return obj;
}
} // namespace
Napi::Value SpectrogramConfigure(const Napi::CallbackInfo& info) {
@@ -386,21 +341,22 @@ Napi::Value SpectrogramProcess(const Napi::CallbackInfo& info) {
Napi::Float32Array audioData = info[0].As<Napi::Float32Array>();
auto result = spectrogramAnalyzer.process(audioData.Data(), audioData.ElementLength());
return SpectrogramResultToJs(env, result);
}
Napi::Value SpectrogramProcessStereo(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) {
Napi::TypeError::New(env, "Expected left and right Float32Arrays").ThrowAsJavaScriptException();
return env.Null();
Napi::Float32Array display = Napi::Float32Array::New(env, result.display.size());
Napi::Float32Array heat = Napi::Float32Array::New(env, result.heat.size());
if (!result.display.empty()) {
memcpy(display.Data(), result.display.data(), result.display.size() * sizeof(float));
}
if (!result.heat.empty()) {
memcpy(heat.Data(), result.heat.data(), result.heat.size() * sizeof(float));
}
Napi::Float32Array left = info[0].As<Napi::Float32Array>();
Napi::Float32Array right = info[1].As<Napi::Float32Array>();
const size_t length = std::min(left.ElementLength(), right.ElementLength());
auto result = spectrogramAnalyzer.processStereo(left.Data(), right.Data(), length);
return SpectrogramResultToJs(env, result);
Napi::Object obj = Napi::Object::New(env);
obj.Set("display", display);
obj.Set("heat", heat);
obj.Set("columnCount", Napi::Number::New(env, static_cast<double>(result.columnCount)));
obj.Set("rowCount", Napi::Number::New(env, static_cast<double>(result.rowCount)));
return obj;
}
Napi::Value SpectrogramReset(const Napi::CallbackInfo& info) {
@@ -729,11 +685,9 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
specExports.Set("fillRawMagnitudes", Napi::Function::New(env, SpectrumFillRawMagnitudes));
specExports.Set("fillMagnitudes", Napi::Function::New(env, SpectrumFillMagnitudes));
specExports.Set("fillSideMagnitudes", Napi::Function::New(env, SpectrumFillSideMagnitudes));
specExports.Set("fillChannelMaxMagnitudes", Napi::Function::New(env, SpectrumFillChannelMaxMagnitudes));
specExports.Set("getRawMagnitudes", Napi::Function::New(env, SpectrumGetRawMagnitudes));
specExports.Set("getMagnitudes", Napi::Function::New(env, SpectrumGetMagnitudes));
specExports.Set("getSideMagnitudes", Napi::Function::New(env, SpectrumGetSideMagnitudes));
specExports.Set("getChannelMaxMagnitudes", Napi::Function::New(env, SpectrumGetChannelMaxMagnitudes));
specExports.Set("process", Napi::Function::New(env, SpectrumProcess));
specExports.Set("binToFrequency", Napi::Function::New(env, SpectrumBinToFrequency));
specExports.Set("reset", Napi::Function::New(env, SpectrumReset));
@@ -743,7 +697,6 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
Napi::Object spectrogramExports = Napi::Object::New(env);
spectrogramExports.Set("configure", Napi::Function::New(env, SpectrogramConfigure));
spectrogramExports.Set("process", Napi::Function::New(env, SpectrogramProcess));
spectrogramExports.Set("processStereo", Napi::Function::New(env, SpectrogramProcessStereo));
spectrogramExports.Set("reset", Napi::Function::New(env, SpectrogramReset));
exports.Set("spectrogram", spectrogramExports);
@@ -785,9 +738,9 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
lufsExports.Set("reset", Napi::Function::New(env, LUFSMeterReset));
exports.Set("lufsmeter", lufsExports);
RegisterSystemAudioCapture(env, exports);
RegisterWindowsMedia(env, exports);
RegisterWindowChrome(env, exports);
RegisterMacOSCapture(env, exports);
RegisterWindowsCapture(env, exports);
RegisterLinuxCapture(env, exports);
return exports;
}
-7
View File
@@ -12,7 +12,6 @@ Oscilloscope::Oscilloscope()
, lastFilterPitch_(200.0f)
, lastTrigger_(0)
, smoothedPitch_(200.0f)
, latestDetectedPitch_(0.0f)
, pitchSamplesProcessed_(0) {
// Initialize circular buffers
@@ -145,10 +144,6 @@ OscilloscopeResult Oscilloscope::process() {
result.detectedPitch = smoothedPitch_;
if (!pitchLock_) {
const size_t samples = static_cast<size_t>(displaySamples_);
result.triggerIndex = static_cast<float>(
(writePos_ + OSCILLOSCOPE_BUFFER_SIZE - samples) %
OSCILLOSCOPE_BUFFER_SIZE);
return result;
}
@@ -177,7 +172,6 @@ OscilloscopeResult Oscilloscope::process() {
float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f);
if (newPitch > 0.0f) {
latestDetectedPitch_ = newPitch;
pitchSamplesProcessed_++;
// Adaptive smoothing: fast convergence initially, then conservative
@@ -313,7 +307,6 @@ void Oscilloscope::reset() {
writePos_ = 0;
lastTrigger_ = 0.0f;
smoothedPitch_ = 200.0f;
latestDetectedPitch_ = 0.0f;
lastFilterPitch_ = 200.0f;
pitchSamplesProcessed_ = 0; // Reset warmup counter for fast convergence on next use
-5
View File
@@ -36,10 +36,6 @@ public:
// Get current write position
size_t getWritePos() const { return writePos_; }
// Latest unsmoothed detector result. The stable detectedPitch returned by
// process() remains the value used for pitch-locked triggering.
float getLatestDetectedPitch() const { return latestDetectedPitch_; }
// Get samples from circular buffer (for rendering)
void getSamples(float* output, size_t startPos, size_t count) const;
@@ -79,7 +75,6 @@ private:
float lastTrigger_;
float smoothedPitch_;
float latestDetectedPitch_;
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
// Internal helpers
+84 -296
View File
@@ -9,10 +9,10 @@ namespace Visualizer {
namespace {
constexpr size_t FFT_PAD_FACTOR = 4;
constexpr float HANN_EQUIVALENT_NOISE_BANDWIDTH_BINS = 1.5f;
constexpr float REASSIGNED_POWER_NORMALIZATION = static_cast<float>(FFT_PAD_FACTOR)
* HANN_EQUIVALENT_NOISE_BANDWIDTH_BINS;
constexpr float DISPLAY_GAIN_DB = 2.0f;
constexpr float SPECTROGRAM_HEAT_GAMMA = 1.45f;
constexpr float SPECTROGRAM_DISPLAY_STROKE_WEIGHT = 0.42f;
constexpr float SPECTROGRAM_HEAT_STROKE_WEIGHT = 0.32f;
constexpr float TILT_REFERENCE_HZ = 1000.0f;
constexpr float HEAT_MIN_DB = -100.0f;
constexpr float HEAT_MAX_DB = -20.0f;
@@ -64,8 +64,7 @@ SpectrogramAnalyzer::SpectrogramAnalyzer()
: fftSize_(0)
, paddedSize_(0)
, frameFill_(0)
, haveLastPhase_(false)
, magnitudeScale_(1.0f) {
, haveLastPhase_(false) {
configureFft(config_.fftSize);
rebuildFrequencyMapping();
}
@@ -97,10 +96,7 @@ void SpectrogramAnalyzer::configure(const SpectrogramConfig& config) {
if (next.orientation != "vertical") {
next.orientation = "horizontal";
}
if (next.clarityMode != "classic"
&& next.clarityMode != "focused"
&& next.clarityMode != "sharp"
&& next.clarityMode != "sharper") {
if (next.clarityMode != "classic" && next.clarityMode != "sharp" && next.clarityMode != "sharper") {
next.clarityMode = "sharper";
}
@@ -132,19 +128,13 @@ void SpectrogramAnalyzer::configureFft(size_t fftSize) {
paddedSize_ = fftSize_ * FFT_PAD_FACTOR;
fft_ = std::make_unique<DSP::FFT>(paddedSize_);
frameBuffer_.assign(fftSize_, 0.0f);
rightFrameBuffer_.assign(fftSize_, 0.0f);
window_.assign(fftSize_, 1.0f);
windowedInput_.assign(paddedSize_, 0.0f);
rightWindowedInput_.assign(paddedSize_, 0.0f);
fftOutput_.assign(paddedSize_, std::complex<float>(0.0f, 0.0f));
rightFftOutput_.assign(paddedSize_, std::complex<float>(0.0f, 0.0f));
magnitudesDb_.assign(paddedSize_ / 2, -200.0f);
magnitudesLinear_.assign(paddedSize_ / 2, 0.0f);
phases_.assign(paddedSize_ / 2, 0.0f);
lastPhases_.assign(paddedSize_ / 2, 0.0f);
rightPhases_.assign(paddedSize_ / 2, 0.0f);
rightLastPhases_.assign(paddedSize_ / 2, 0.0f);
dominantRight_.assign(paddedSize_ / 2, 0);
frameFill_ = 0;
haveLastPhase_ = false;
@@ -155,21 +145,11 @@ void SpectrogramAnalyzer::configureFft(size_t fftSize) {
for (size_t index = 0; index < fftSize_; index += 1) {
window_[index] = 0.5f * (1.0f - std::cos((2.0f * static_cast<float>(M_PI) * index) / (fftSize_ - 1)));
}
float windowSum = 0.0f;
for (const float coefficient : window_) {
windowSum += coefficient;
}
magnitudeScale_ = windowSum > std::numeric_limits<float>::epsilon()
? 2.0f / windowSum
: 1.0f;
}
void SpectrogramAnalyzer::reset() {
std::fill(frameBuffer_.begin(), frameBuffer_.end(), 0.0f);
std::fill(rightFrameBuffer_.begin(), rightFrameBuffer_.end(), 0.0f);
std::fill(lastPhases_.begin(), lastPhases_.end(), 0.0f);
std::fill(rightLastPhases_.begin(), rightLastPhases_.end(), 0.0f);
frameFill_ = 0;
haveLastPhase_ = false;
}
@@ -186,7 +166,7 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() {
const float sampleRate = std::max(1.0f, config_.sampleRate);
const float nyquist = sampleRate * 0.5f;
const float minFrequency = std::max(1.0f, std::min(config_.minFrequency, nyquist));
const float maxFrequency = std::max(minFrequency, std::min(config_.maxFrequency, nyquist));
const float maxFrequency = std::max(minFrequency + 1.0f, std::min(config_.maxFrequency, nyquist));
config_.minFrequency = minFrequency;
config_.maxFrequency = maxFrequency;
@@ -197,9 +177,8 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() {
standardRaw_.assign(rowCount, 0.0f);
standardHeat_.assign(rowCount, 0.0f);
reassignedPower_.assign(rowCount, 0.0f);
focusedPower_.assign(rowCount, 0.0f);
sourceRaw_.assign(rowCount, 0.0f);
sourceHeat_.assign(rowCount, 0.0f);
blendedRaw_.assign(rowCount, 0.0f);
blendedHeat_.assign(rowCount, 0.0f);
shapedDisplay_.assign(rowCount, 0.0f);
shapedHeat_.assign(rowCount, 0.0f);
strokedDisplay_.assign(rowCount, 0.0f);
@@ -239,11 +218,7 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() {
float SpectrogramAnalyzer::frequencyFromScale(float normalizedPosition) const {
const float t = clamp01(normalizedPosition);
const float minFrequency = std::max(1.0f, config_.minFrequency);
const float maxFrequency = std::max(minFrequency, config_.maxFrequency);
if (maxFrequency <= minFrequency) {
return minFrequency;
}
const float maxFrequency = std::max(minFrequency + 1.0f, config_.maxFrequency);
if (config_.scaleMode == "linear") {
return minFrequency + (t * (maxFrequency - minFrequency));
@@ -261,15 +236,8 @@ float SpectrogramAnalyzer::frequencyFromScale(float normalizedPosition) const {
}
float SpectrogramAnalyzer::frequencyToRow(float frequency) const {
if (config_.rowCount <= 1) {
return 0.0f;
}
const float minFrequency = std::max(1.0f, config_.minFrequency);
const float maxFrequency = std::max(minFrequency, config_.maxFrequency);
if (maxFrequency <= minFrequency) {
return config_.orientation == "vertical" ? 0.0f : static_cast<float>(config_.rowCount - 1);
}
const float maxFrequency = std::max(minFrequency + 1.0f, config_.maxFrequency);
const float clampedFrequency = std::clamp(frequency, minFrequency, maxFrequency);
float normalized = 0.0f;
@@ -294,7 +262,7 @@ float SpectrogramAnalyzer::frequencyToRow(float frequency) const {
float SpectrogramAnalyzer::applyDisplayTilt(float db, float frequency) const {
const float safeFrequency = std::max(1.0f, frequency);
const float tiltAmount = config_.tiltDbPerOctave * std::log2(safeFrequency / TILT_REFERENCE_HZ);
return db + tiltAmount;
return db + tiltAmount + DISPLAY_GAIN_DB;
}
float SpectrogramAnalyzer::displayDbToIntensity(float db) const {
@@ -328,44 +296,10 @@ float SpectrogramAnalyzer::sampleDbAtBin(float bin) const {
);
}
float SpectrogramAnalyzer::samplePeakDbInBand(float startBin, float endBin, float& peakBin) const {
if (magnitudesDb_.empty()) {
peakBin = 0.0f;
return -200.0f;
}
const float lastBin = static_cast<float>(magnitudesDb_.size() - 1);
const float lo = std::clamp(std::min(startBin, endBin), 0.0f, lastBin);
const float hi = std::clamp(std::max(startBin, endBin), 0.0f, lastBin);
peakBin = 0.5f * (lo + hi);
float peakDb = sampleDbAtBin(peakBin);
const auto consider = [&](float candidateBin) {
const float candidateDb = sampleDbAtBin(candidateBin);
if (candidateDb > peakDb) {
peakDb = candidateDb;
peakBin = candidateBin;
}
};
consider(lo);
consider(hi);
const size_t firstWholeBin = static_cast<size_t>(std::ceil(lo));
const size_t lastWholeBin = static_cast<size_t>(std::floor(hi));
for (size_t bin = firstWholeBin; bin <= lastWholeBin && bin < magnitudesDb_.size(); bin += 1) {
consider(static_cast<float>(bin));
}
return peakDb;
}
void SpectrogramAnalyzer::computeStandardSpectrum() {
const size_t rowCount = config_.rowCount;
const float binWidth = std::max(1.0f, config_.sampleRate) / static_cast<float>(paddedSize_);
for (size_t row = 0; row < rowCount; row += 1) {
float peakBin = rowCenterBins_[row];
const float rawDb = samplePeakDbInBand(rowBandStartBins_[row], rowBandEndBins_[row], peakBin);
const float displayDb = applyDisplayTilt(rawDb, peakBin * binWidth);
const float displayDb = applyDisplayTilt(sampleDbAtBin(rowCenterBins_[row]), rowCenterFrequencies_[row]);
standardRaw_[row] = displayDbToIntensity(displayDb);
standardHeat_[row] = normalizeHeatDb(displayDb);
}
@@ -380,11 +314,7 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() {
const float sampleRate = std::max(1.0f, config_.sampleRate);
const float binWidth = sampleRate / static_cast<float>(paddedSize_);
const float hopDt = static_cast<float>(resolveHopSize()) / sampleRate;
// Reassign every bin that can contribute to either output. Limiting this to
// local maxima throws away low-level partials and ambience — exactly the
// detail a sharpened spectrogram is meant to retain.
const float visibleFloorDb = std::min(config_.minDecibels, HEAT_MIN_DB);
const float ampThreshold = std::pow(10.0f, visibleFloorDb / 20.0f);
const float ampThreshold = std::pow(10.0f, config_.minDecibels / 20.0f);
const float twoPi = static_cast<float>(2.0 * M_PI);
for (size_t bin = 1; bin + 1 < magnitudesLinear_.size(); bin += 1) {
@@ -392,59 +322,7 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() {
if (mag <= ampThreshold) {
continue;
}
const float nominalFrequency = static_cast<float>(bin) * binWidth;
if (nominalFrequency < config_.minFrequency || nominalFrequency > config_.maxFrequency) {
continue;
}
const float expected = twoPi * nominalFrequency * hopDt;
const bool useRightPhase = dominantRight_[bin] != 0;
const float currentPhase = useRightPhase ? rightPhases_[bin] : phases_[bin];
const float previousPhase = useRightPhase ? rightLastPhases_[bin] : lastPhases_[bin];
const float correctionHz = wrapPhase(currentPhase - previousPhase - expected) / (twoPi * hopDt);
const float reassignedFrequency = nominalFrequency + correctionHz;
if (reassignedFrequency < config_.minFrequency || reassignedFrequency > config_.maxFrequency) {
continue;
}
const float rowF = frequencyToRow(reassignedFrequency);
const size_t row0 = static_cast<size_t>(std::floor(std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))));
const float frac = rowF - static_cast<float>(row0);
// A coherently-normalized Hann spectrum contains 1.5 bins of equivalent
// noise bandwidth. With 4x zero padding, a bin-centered sinusoid therefore
// contributes 6x its signal power across the positive-frequency bins.
// Divide that back out so relocation conserves calibrated signal power.
const float power = (mag * mag) / REASSIGNED_POWER_NORMALIZATION;
reassignedPower_[row0] += power * (1.0f - frac);
if (row0 + 1 < config_.rowCount) {
reassignedPower_[row0 + 1] += power * frac;
}
}
}
void SpectrogramAnalyzer::computeFocusedSpectrum() {
std::fill(focusedPower_.begin(), focusedPower_.end(), 0.0f);
if (!haveLastPhase_ || magnitudesLinear_.size() < 3 || config_.rowCount == 0) {
return;
}
const float sampleRate = std::max(1.0f, config_.sampleRate);
const float binWidth = sampleRate / static_cast<float>(paddedSize_);
const float hopDt = static_cast<float>(resolveHopSize()) / sampleRate;
const float ampThreshold = std::pow(10.0f, config_.minDecibels / 20.0f);
const float twoPi = static_cast<float>(2.0 * M_PI);
// Focused intentionally restores Prism's former peak-isolation aesthetic:
// only local FFT maxima are relocated, with conservative phase correction
// and a local spectral centroid. It is kept separate from Sharp/Sharper so
// their energy-preserving reassignment cannot silently lose texture.
for (size_t bin = 1; bin + 1 < magnitudesLinear_.size(); bin += 1) {
const float mag = magnitudesLinear_[bin];
if (mag <= ampThreshold
|| mag < magnitudesLinear_[bin - 1]
|| mag < magnitudesLinear_[bin + 1]) {
if (mag < magnitudesLinear_[bin - 1] || mag < magnitudesLinear_[bin + 1]) {
continue;
}
@@ -454,12 +332,9 @@ void SpectrogramAnalyzer::computeFocusedSpectrum() {
}
const float expected = twoPi * nominalFrequency * hopDt;
const bool useRightPhase = dominantRight_[bin] != 0;
const float currentPhase = useRightPhase ? rightPhases_[bin] : phases_[bin];
const float previousPhase = useRightPhase ? rightLastPhases_[bin] : lastPhases_[bin];
float correctionHz = wrapPhase(currentPhase - previousPhase - expected) / (twoPi * hopDt);
float correctionHz = wrapPhase(phases_[bin] - lastPhases_[bin] - expected) / (twoPi * hopDt);
correctionHz = std::clamp(correctionHz, -1.5f * binWidth, 1.5f * binWidth);
float focusedFrequency = nominalFrequency + correctionHz;
float reassignedFrequency = nominalFrequency + correctionHz;
const float leftWeight = magnitudesLinear_[bin - 1];
const float centerWeight = mag;
@@ -471,163 +346,97 @@ void SpectrogramAnalyzer::computeFocusedSpectrum() {
+ (nominalFrequency * centerWeight)
+ (static_cast<float>(bin + 1) * binWidth * rightWeight)
) / weightSum;
focusedFrequency = 0.5f * focusedFrequency + 0.5f * centroidFrequency;
reassignedFrequency = 0.5f * reassignedFrequency + 0.5f * centroidFrequency;
}
if (focusedFrequency < config_.minFrequency || focusedFrequency > config_.maxFrequency) {
continue;
}
const float rowF = frequencyToRow(focusedFrequency);
const size_t row0 = static_cast<size_t>(std::floor(
std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))
));
reassignedFrequency = std::clamp(reassignedFrequency, config_.minFrequency, config_.maxFrequency);
const float rowF = frequencyToRow(reassignedFrequency);
const size_t row0 = static_cast<size_t>(std::floor(std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))));
const float frac = rowF - static_cast<float>(row0);
const float power = mag * mag;
focusedPower_[row0] += power * (1.0f - frac);
reassignedPower_[row0] += power * (1.0f - frac);
if (row0 + 1 < config_.rowCount) {
focusedPower_[row0 + 1] += power * frac;
reassignedPower_[row0 + 1] += power * frac;
}
}
}
SpectrogramAnalyzer::ClarityProfile SpectrogramAnalyzer::clarityProfile(const std::string& mode) {
if (mode == "classic") {
return {1.4f, 0.42f, 0.32f, false};
return {1.4f, 0.0f, 3.0f};
}
if (mode == "sharp") {
return {1.25f, 0.22f, 0.16f, true};
return {1.5f, 2.5f, 3.0f};
}
return {1.1f, 0.08f, 0.06f, true};
return {2.0f, 5.0f, 2.0f};
}
void SpectrogramAnalyzer::shapeColumn(std::vector<float>& display, std::vector<float>& heat) {
if (config_.clarityMode == "focused") {
shapeFocusedColumn(display, heat);
return;
}
void SpectrogramAnalyzer::blendAndShapeColumn(std::vector<float>& display, std::vector<float>& heat) {
const size_t rowCount = config_.rowCount;
const ClarityProfile clarity = clarityProfile(config_.clarityMode);
const bool useReassignedColumn = clarity.useReassignment && haveLastPhase_;
const float standardWeight = config_.clarityMode == "classic" ? 0.8f : (config_.clarityMode == "sharp" ? 0.6f : 0.45f);
const float reassignedWeight = config_.clarityMode == "classic" ? 0.85f : 1.0f;
for (size_t row = 0; row < rowCount; row += 1) {
if (useReassignedColumn && reassignedPower_[row] > 0.0f) {
float reassignedRaw = 0.0f;
float reassignedHeat = 0.0f;
if (reassignedPower_[row] > 0.0f) {
const float reassignedMag = std::sqrt(reassignedPower_[row]);
const float reassignedDb = 20.0f * std::log10(std::max(reassignedMag, 1.0e-10f));
const float displayDb = applyDisplayTilt(reassignedDb, rowCenterFrequencies_[row]);
sourceRaw_[row] = displayDbToIntensity(displayDb);
sourceHeat_[row] = normalizeHeatDb(displayDb);
} else if (!useReassignedColumn) {
// The first phase-history frame falls back to Classic. Subsequent Sharp
// and Sharper frames contain only reassigned energy — no hidden Classic
// layer and no local-contrast gate.
sourceRaw_[row] = standardRaw_[row];
sourceHeat_[row] = standardHeat_[row];
} else {
sourceRaw_[row] = 0.0f;
sourceHeat_[row] = 0.0f;
reassignedRaw = displayDbToIntensity(displayDb);
reassignedHeat = normalizeHeatDb(displayDb);
}
blendedRaw_[row] = std::max(standardRaw_[row] * standardWeight, reassignedRaw * reassignedWeight);
blendedHeat_[row] = std::max(standardHeat_[row] * standardWeight, reassignedHeat * reassignedWeight);
}
if (clarity.sharpness > 0.0f) {
const std::vector<float> peakSource = blendedRaw_;
const float mainlobePaddedBins = 4.0f * static_cast<float>(FFT_PAD_FACTOR);
const float detailPreserve = config_.clarityMode == "sharp" ? 0.18f : 0.14f;
for (size_t row = 0; row < rowCount; row += 1) {
const float bandWidthPerRow = std::max(0.1f, rowBandEndBins_[row] - rowBandStartBins_[row]);
const float mainlobePixels = mainlobePaddedBins / bandWidthPerRow;
const int halfWindow = std::max(2, std::min(50, static_cast<int>(std::lround(mainlobePixels * 0.5f))));
const float scaleFactor = std::max(1.0f, mainlobePixels / clarity.lineWidth);
const float effectiveSharpness = clarity.sharpness * scaleFactor;
float localMax = peakSource[row];
for (int offset = 1; offset <= halfWindow; offset += 1) {
if (row >= static_cast<size_t>(offset)) {
localMax = std::max(localMax, peakSource[row - static_cast<size_t>(offset)]);
}
if (row + static_cast<size_t>(offset) < rowCount) {
localMax = std::max(localMax, peakSource[row + static_cast<size_t>(offset)]);
}
}
if (localMax > 1.0e-6f) {
const float ratio = blendedRaw_[row] / localMax;
const float suppression = std::pow(clamp01(ratio), effectiveSharpness);
const float rawBefore = blendedRaw_[row];
const float heatBefore = blendedHeat_[row];
blendedRaw_[row] = std::max(rawBefore * suppression, rawBefore * detailPreserve);
blendedHeat_[row] = std::max(heatBefore * suppression, heatBefore * detailPreserve);
}
}
}
const float effectiveGamma = clarity.gamma * config_.contrast;
for (size_t row = 0; row < rowCount; row += 1) {
shapedDisplay_[row] = std::pow(clamp01(sourceRaw_[row]), effectiveGamma);
shapedHeat_[row] = std::pow(clamp01(sourceHeat_[row]), SPECTROGRAM_HEAT_GAMMA);
shapedDisplay_[row] = std::pow(clamp01(blendedRaw_[row]), effectiveGamma);
shapedHeat_[row] = std::pow(clamp01(blendedHeat_[row]), SPECTROGRAM_HEAT_GAMMA);
strokedDisplay_[row] = shapedDisplay_[row];
strokedHeat_[row] = shapedHeat_[row];
}
for (size_t row = 0; row < rowCount; row += 1) {
const float displayShoulder = shapedDisplay_[row] * clarity.displayShoulder;
const float heatShoulder = shapedHeat_[row] * clarity.heatShoulder;
if (row > 0) {
strokedDisplay_[row - 1] = std::max(strokedDisplay_[row - 1], displayShoulder);
strokedHeat_[row - 1] = std::max(strokedHeat_[row - 1], heatShoulder);
}
if (row + 1 < rowCount) {
strokedDisplay_[row + 1] = std::max(strokedDisplay_[row + 1], displayShoulder);
strokedHeat_[row + 1] = std::max(strokedHeat_[row + 1], heatShoulder);
}
}
const size_t offset = display.size();
display.resize(offset + rowCount);
heat.resize(offset + rowCount);
for (size_t row = 0; row < rowCount; row += 1) {
display[offset + row] = strokedDisplay_[row];
heat[offset + row] = strokedHeat_[row];
}
}
void SpectrogramAnalyzer::shapeFocusedColumn(std::vector<float>& display, std::vector<float>& heat) {
const size_t rowCount = config_.rowCount;
constexpr float standardWeight = 0.45f;
constexpr float reassignedWeight = 1.0f;
constexpr float sharpness = 5.0f;
constexpr float lineWidth = 2.0f;
constexpr float detailPreserve = 0.14f;
constexpr float gamma = 2.0f;
constexpr float displayShoulderWeight = 0.42f;
constexpr float heatShoulderWeight = 0.32f;
for (size_t row = 0; row < rowCount; row += 1) {
float focusedRaw = 0.0f;
float focusedHeat = 0.0f;
if (focusedPower_[row] > 0.0f) {
const float focusedMag = std::sqrt(focusedPower_[row]);
const float focusedDb = 20.0f * std::log10(std::max(focusedMag, 1.0e-10f));
const float displayDb = applyDisplayTilt(focusedDb, rowCenterFrequencies_[row]);
focusedRaw = displayDbToIntensity(displayDb);
focusedHeat = normalizeHeatDb(displayDb);
}
sourceRaw_[row] = std::max(standardRaw_[row] * standardWeight, focusedRaw * reassignedWeight);
sourceHeat_[row] = std::max(standardHeat_[row] * standardWeight, focusedHeat * reassignedWeight);
}
const std::vector<float> peakSource = sourceRaw_;
const float mainlobePaddedBins = 4.0f * static_cast<float>(FFT_PAD_FACTOR);
for (size_t row = 0; row < rowCount; row += 1) {
const float bandWidthPerRow = std::max(0.1f, rowBandEndBins_[row] - rowBandStartBins_[row]);
const float mainlobePixels = mainlobePaddedBins / bandWidthPerRow;
const int halfWindow = std::max(
2,
std::min(50, static_cast<int>(std::lround(mainlobePixels * 0.5f)))
);
const float scaleFactor = std::max(1.0f, mainlobePixels / lineWidth);
const float effectiveSharpness = sharpness * scaleFactor;
float localMax = peakSource[row];
for (int offset = 1; offset <= halfWindow; offset += 1) {
if (row >= static_cast<size_t>(offset)) {
localMax = std::max(localMax, peakSource[row - static_cast<size_t>(offset)]);
}
if (row + static_cast<size_t>(offset) < rowCount) {
localMax = std::max(localMax, peakSource[row + static_cast<size_t>(offset)]);
}
}
if (localMax > 1.0e-6f) {
const float suppression = std::pow(clamp01(sourceRaw_[row] / localMax), effectiveSharpness);
const float rawBefore = sourceRaw_[row];
const float heatBefore = sourceHeat_[row];
sourceRaw_[row] = std::max(rawBefore * suppression, rawBefore * detailPreserve);
sourceHeat_[row] = std::max(heatBefore * suppression, heatBefore * detailPreserve);
}
}
const float effectiveGamma = gamma * config_.contrast;
for (size_t row = 0; row < rowCount; row += 1) {
shapedDisplay_[row] = std::pow(clamp01(sourceRaw_[row]), effectiveGamma);
shapedHeat_[row] = std::pow(clamp01(sourceHeat_[row]), SPECTROGRAM_HEAT_GAMMA);
strokedDisplay_[row] = shapedDisplay_[row];
strokedHeat_[row] = shapedHeat_[row];
}
for (size_t row = 0; row < rowCount; row += 1) {
const float displayShoulder = shapedDisplay_[row] * displayShoulderWeight;
const float heatShoulder = shapedHeat_[row] * heatShoulderWeight;
const float displayShoulder = shapedDisplay_[row] * SPECTROGRAM_DISPLAY_STROKE_WEIGHT;
const float heatShoulder = shapedHeat_[row] * SPECTROGRAM_HEAT_STROKE_WEIGHT;
if (row > 0) {
strokedDisplay_[row - 1] = std::max(strokedDisplay_[row - 1], displayShoulder);
strokedHeat_[row - 1] = std::max(strokedHeat_[row - 1], heatShoulder);
@@ -649,54 +458,35 @@ void SpectrogramAnalyzer::shapeFocusedColumn(std::vector<float>& display, std::v
void SpectrogramAnalyzer::processFrame(std::vector<float>& display, std::vector<float>& heat) {
std::fill(windowedInput_.begin(), windowedInput_.end(), 0.0f);
std::fill(rightWindowedInput_.begin(), rightWindowedInput_.end(), 0.0f);
for (size_t index = 0; index < fftSize_; index += 1) {
windowedInput_[index] = frameBuffer_[index] * window_[index];
rightWindowedInput_[index] = rightFrameBuffer_[index] * window_[index];
}
fft_->forward(windowedInput_.data(), fftOutput_.data());
fft_->forward(rightWindowedInput_.data(), rightFftOutput_.data());
const size_t numBins = paddedSize_ / 2;
const float scale = 2.0f / static_cast<float>(fftSize_);
for (size_t bin = 0; bin < numBins; bin += 1) {
const float leftRe = fftOutput_[bin].real();
const float leftIm = fftOutput_[bin].imag();
const float rightRe = rightFftOutput_[bin].real();
const float rightIm = rightFftOutput_[bin].imag();
const float leftMagnitude = std::sqrt((leftRe * leftRe) + (leftIm * leftIm)) * magnitudeScale_;
const float rightMagnitude = std::sqrt((rightRe * rightRe) + (rightIm * rightIm)) * magnitudeScale_;
const float stereoMagnitude = std::sqrt(
0.5f * ((leftMagnitude * leftMagnitude) + (rightMagnitude * rightMagnitude))
);
magnitudesLinear_[bin] = stereoMagnitude;
magnitudesDb_[bin] = 20.0f * std::log10(std::max(stereoMagnitude, 1.0e-10f));
phases_[bin] = std::atan2(leftIm, leftRe);
rightPhases_[bin] = std::atan2(rightIm, rightRe);
dominantRight_[bin] = rightMagnitude > leftMagnitude ? 1 : 0;
const float re = fftOutput_[bin].real();
const float im = fftOutput_[bin].imag();
const float magnitude = std::sqrt((re * re) + (im * im)) * scale;
magnitudesLinear_[bin] = magnitude;
magnitudesDb_[bin] = 20.0f * std::log10(std::max(magnitude, 1.0e-10f));
phases_[bin] = std::atan2(im, re);
}
computeStandardSpectrum();
if (config_.clarityMode == "focused") {
computeFocusedSpectrum();
} else {
computeReassignedSpectrum();
}
shapeColumn(display, heat);
computeReassignedSpectrum();
blendAndShapeColumn(display, heat);
lastPhases_ = phases_;
rightLastPhases_ = rightPhases_;
haveLastPhase_ = true;
}
SpectrogramProcessResult SpectrogramAnalyzer::process(const float* samples, size_t length) {
return processStereo(samples, samples, length);
}
SpectrogramProcessResult SpectrogramAnalyzer::processStereo(const float* left, const float* right, size_t length) {
SpectrogramProcessResult result;
result.rowCount = config_.rowCount;
if (!left || !right || length == 0 || fftSize_ == 0 || config_.rowCount == 0) {
if (!samples || length == 0 || fftSize_ == 0 || config_.rowCount == 0) {
return result;
}
@@ -704,8 +494,7 @@ SpectrogramProcessResult SpectrogramAnalyzer::processStereo(const float* left, c
const size_t overlapSamples = fftSize_ - hopSize;
for (size_t index = 0; index < length; index += 1) {
frameBuffer_[frameFill_] = left[index];
rightFrameBuffer_[frameFill_] = right[index];
frameBuffer_[frameFill_] = samples[index];
frameFill_ += 1;
if (frameFill_ >= fftSize_) {
@@ -714,7 +503,6 @@ SpectrogramProcessResult SpectrogramAnalyzer::processStereo(const float* left, c
if (overlapSamples > 0) {
std::memmove(frameBuffer_.data(), frameBuffer_.data() + hopSize, overlapSamples * sizeof(float));
std::memmove(rightFrameBuffer_.data(), rightFrameBuffer_.data() + hopSize, overlapSamples * sizeof(float));
}
frameFill_ = overlapSamples;
}
+5 -19
View File
@@ -1,7 +1,6 @@
#pragma once
#include "dsp_utils.h"
#include <cstdint>
#include <complex>
#include <memory>
#include <string>
@@ -38,15 +37,13 @@ public:
void configure(const SpectrogramConfig& config);
SpectrogramProcessResult process(const float* samples, size_t length);
SpectrogramProcessResult processStereo(const float* left, const float* right, size_t length);
void reset();
private:
struct ClarityProfile {
float gamma;
float displayShoulder;
float heatShoulder;
bool useReassignment;
float sharpness;
float lineWidth;
};
SpectrogramConfig config_;
@@ -54,23 +51,16 @@ private:
size_t paddedSize_;
size_t frameFill_;
bool haveLastPhase_;
float magnitudeScale_;
std::unique_ptr<DSP::FFT> fft_;
std::vector<float> frameBuffer_;
std::vector<float> rightFrameBuffer_;
std::vector<float> window_;
std::vector<float> windowedInput_;
std::vector<float> rightWindowedInput_;
std::vector<std::complex<float>> fftOutput_;
std::vector<std::complex<float>> rightFftOutput_;
std::vector<float> magnitudesDb_;
std::vector<float> magnitudesLinear_;
std::vector<float> phases_;
std::vector<float> lastPhases_;
std::vector<float> rightPhases_;
std::vector<float> rightLastPhases_;
std::vector<uint8_t> dominantRight_;
std::vector<float> rowCenterBins_;
std::vector<float> rowBandStartBins_;
@@ -79,9 +69,8 @@ private:
std::vector<float> standardRaw_;
std::vector<float> standardHeat_;
std::vector<float> reassignedPower_;
std::vector<float> focusedPower_;
std::vector<float> sourceRaw_;
std::vector<float> sourceHeat_;
std::vector<float> blendedRaw_;
std::vector<float> blendedHeat_;
std::vector<float> shapedDisplay_;
std::vector<float> shapedHeat_;
std::vector<float> strokedDisplay_;
@@ -92,12 +81,9 @@ private:
void processFrame(std::vector<float>& display, std::vector<float>& heat);
void computeStandardSpectrum();
void computeReassignedSpectrum();
void computeFocusedSpectrum();
void shapeColumn(std::vector<float>& display, std::vector<float>& heat);
void shapeFocusedColumn(std::vector<float>& display, std::vector<float>& heat);
void blendAndShapeColumn(std::vector<float>& display, std::vector<float>& heat);
size_t resolveHopSize() const;
float sampleDbAtBin(float bin) const;
float samplePeakDbInBand(float startBin, float endBin, float& peakBin) const;
float frequencyFromScale(float normalizedPosition) const;
float frequencyToRow(float frequency) const;
float applyDisplayTilt(float db, float frequency) const;
+60 -53
View File
@@ -15,16 +15,12 @@ Spectrum::Spectrum(size_t fftSize)
historyBuffer_.resize(fftSize, 0.0f);
sideHistoryBuffer_.resize(fftSize, 0.0f);
windowedInput_.resize(fftSize);
midSpectrum_.resize(fftSize);
sideSpectrum_.resize(fftSize);
magnitudes_.resize(fftSize / 2);
rawMagnitudes_.resize(fftSize / 2, -100.0f);
// Initialize to silence (-100.0f dB)
smoothedMagnitudes_.resize(fftSize / 2, -100.0f);
sideRawMagnitudes_.resize(fftSize / 2, -100.0f);
sideSmoothedMagnitudes_.resize(fftSize / 2, -100.0f);
leftSmoothedMagnitudes_.resize(fftSize / 2, -100.0f);
rightSmoothedMagnitudes_.resize(fftSize / 2, -100.0f);
channelMaxMagnitudes_.resize(fftSize / 2, -100.0f);
}
void Spectrum::setFFTSize(size_t size) {
@@ -34,16 +30,12 @@ void Spectrum::setFFTSize(size_t size) {
historyBuffer_.assign(size, 0.0f);
sideHistoryBuffer_.assign(size, 0.0f);
windowedInput_.resize(size);
midSpectrum_.resize(size);
sideSpectrum_.resize(size);
magnitudes_.resize(size / 2);
rawMagnitudes_.assign(size / 2, -100.0f);
// Initialize to silence (-100.0f dB)
smoothedMagnitudes_.assign(size / 2, -100.0f);
sideRawMagnitudes_.assign(size / 2, -100.0f);
sideSmoothedMagnitudes_.assign(size / 2, -100.0f);
leftSmoothedMagnitudes_.assign(size / 2, -100.0f);
rightSmoothedMagnitudes_.assign(size / 2, -100.0f);
channelMaxMagnitudes_.assign(size / 2, -100.0f);
bufferedSamples_ = 0;
}
}
@@ -102,52 +94,69 @@ void Spectrum::pushZeroHistory(std::vector<float>& history, size_t length) {
std::fill(history.begin() + keep, history.end(), 0.0f);
}
float Spectrum::magnitudeToDb(float magnitude, float correctionDb) const {
const float db = 20.0f * log10f(std::max(magnitude, 1e-10f)) + correctionDb;
return std::clamp(db, -120.0f, 12.0f);
}
void Spectrum::updateSmoothedMagnitude(float db, float& smoothedMagnitude) {
if (bufferedSamples_ < fftSize_) {
smoothedMagnitude = db;
} else {
smoothedMagnitude = smoothing_ * smoothedMagnitude + (1.0f - smoothing_) * db;
void Spectrum::updateMagnitudesForHistory(
const std::vector<float>& history,
std::vector<float>& rawMagnitudes,
std::vector<float>& smoothedMagnitudes
) {
if (history.empty() || magnitudes_.empty()) {
return;
}
if (!std::isfinite(smoothedMagnitude)) {
smoothedMagnitude = -100.0f;
// Always analyze a full FFT frame from the rolling buffer.
applyWindow(history.data(), windowedInput_.data(), fftSize_);
// Perform FFT
fft_->forward(windowedInput_.data(), magnitudes_.data());
// Convert to dB and apply smoothing
for (size_t i = 0; i < magnitudes_.size(); i++) {
float mag = magnitudes_[i];
// Convert to dB
// Add epsilon to avoid log(0)
float db = 20.0f * log10f(std::max(mag, 1e-10f));
// Compensate Hann window coherent gain (about -6 dB).
db += 6.0f;
// Clamp to a stable display range.
db = std::clamp(db, -120.0f, 12.0f);
rawMagnitudes[i] = db;
if (bufferedSamples_ < fftSize_) {
smoothedMagnitudes[i] = db;
continue;
}
// Apply temporal smoothing only (no bin-to-bin averaging).
smoothedMagnitudes[i] = smoothing_ * smoothedMagnitudes[i] + (1.0f - smoothing_) * db;
// Safety check
if (!std::isfinite(smoothedMagnitudes[i])) {
smoothedMagnitudes[i] = -100.0f;
}
}
}
void Spectrum::updateMagnitudes() {
if (historyBuffer_.empty() || rawMagnitudes_.empty()) {
return;
}
updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_);
updateMagnitudesForHistory(sideHistoryBuffer_, sideRawMagnitudes_, sideSmoothedMagnitudes_);
}
applyWindow(historyBuffer_.data(), windowedInput_.data(), fftSize_);
fft_->forward(windowedInput_.data(), midSpectrum_.data());
applyWindow(sideHistoryBuffer_.data(), windowedInput_.data(), fftSize_);
fft_->forward(windowedInput_.data(), sideSpectrum_.data());
void Spectrum::updateSilentSideMagnitudes() {
const float silentDb = -120.0f;
for (size_t i = 0; i < sideSmoothedMagnitudes_.size(); i++) {
sideRawMagnitudes_[i] = silentDb;
if (bufferedSamples_ < fftSize_) {
sideSmoothedMagnitudes_[i] = silentDb;
continue;
}
const float scale = 2.0f / static_cast<float>(fftSize_);
const float coherentGain = fftSize_ > 1
? static_cast<float>(fftSize_ - 1) / (2.0f * static_cast<float>(fftSize_))
: 1.0f;
const float correctionDb = -20.0f * log10f(coherentGain);
for (size_t i = 0; i < rawMagnitudes_.size(); i++) {
const std::complex<float> mid = midSpectrum_[i];
const std::complex<float> side = sideSpectrum_[i];
const float midDb = magnitudeToDb(std::abs(mid) * scale, correctionDb);
const float sideDb = magnitudeToDb(std::abs(side) * scale, correctionDb);
const float leftDb = magnitudeToDb(std::abs(mid + side) * scale, correctionDb);
const float rightDb = magnitudeToDb(std::abs(mid - side) * scale, correctionDb);
rawMagnitudes_[i] = midDb;
sideRawMagnitudes_[i] = sideDb;
updateSmoothedMagnitude(midDb, smoothedMagnitudes_[i]);
updateSmoothedMagnitude(sideDb, sideSmoothedMagnitudes_[i]);
updateSmoothedMagnitude(leftDb, leftSmoothedMagnitudes_[i]);
updateSmoothedMagnitude(rightDb, rightSmoothedMagnitudes_[i]);
channelMaxMagnitudes_[i] = std::max(leftSmoothedMagnitudes_[i], rightSmoothedMagnitudes_[i]);
sideSmoothedMagnitudes_[i] = smoothing_ * sideSmoothedMagnitudes_[i] + (1.0f - smoothing_) * silentDb;
if (!std::isfinite(sideSmoothedMagnitudes_[i])) {
sideSmoothedMagnitudes_[i] = -100.0f;
}
}
}
@@ -156,7 +165,8 @@ void Spectrum::pushSamples(const float* input, size_t length) {
pushHistory(historyBuffer_, input, length);
pushZeroHistory(sideHistoryBuffer_, length);
bufferedSamples_ = length >= fftSize_ ? fftSize_ : std::min(fftSize_, bufferedSamples_ + length);
updateMagnitudes();
updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_);
updateSilentSideMagnitudes();
return;
}
updateMagnitudes();
@@ -205,9 +215,6 @@ void Spectrum::reset() {
std::fill(smoothedMagnitudes_.begin(), smoothedMagnitudes_.end(), -100.0f);
std::fill(sideRawMagnitudes_.begin(), sideRawMagnitudes_.end(), -100.0f);
std::fill(sideSmoothedMagnitudes_.begin(), sideSmoothedMagnitudes_.end(), -100.0f);
std::fill(leftSmoothedMagnitudes_.begin(), leftSmoothedMagnitudes_.end(), -100.0f);
std::fill(rightSmoothedMagnitudes_.begin(), rightSmoothedMagnitudes_.end(), -100.0f);
std::fill(channelMaxMagnitudes_.begin(), channelMaxMagnitudes_.end(), -100.0f);
bufferedSamples_ = 0;
}
+7 -8
View File
@@ -26,7 +26,6 @@ public:
// Read the latest smoothed magnitudes without mutating analyzer state.
const std::vector<float>& getMagnitudes() const { return smoothedMagnitudes_; }
const std::vector<float>& getSideMagnitudes() const { return sideSmoothedMagnitudes_; }
const std::vector<float>& getChannelMaxMagnitudes() const { return channelMaxMagnitudes_; }
// Process audio and get spectrum data
// Returns magnitude data (size = fftSize / 2)
@@ -47,22 +46,22 @@ private:
std::vector<float> historyBuffer_;
std::vector<float> sideHistoryBuffer_;
std::vector<float> windowedInput_;
std::vector<std::complex<float>> midSpectrum_;
std::vector<std::complex<float>> sideSpectrum_;
std::vector<float> magnitudes_;
std::vector<float> rawMagnitudes_;
std::vector<float> smoothedMagnitudes_;
std::vector<float> sideRawMagnitudes_;
std::vector<float> sideSmoothedMagnitudes_;
std::vector<float> leftSmoothedMagnitudes_;
std::vector<float> rightSmoothedMagnitudes_;
std::vector<float> channelMaxMagnitudes_;
size_t bufferedSamples_;
void applyWindow(const float* input, float* output, size_t length);
void pushHistory(std::vector<float>& history, const float* input, size_t length);
void pushZeroHistory(std::vector<float>& history, size_t length);
float magnitudeToDb(float magnitude, float correctionDb) const;
void updateSmoothedMagnitude(float db, float& smoothedMagnitude);
void updateMagnitudesForHistory(
const std::vector<float>& history,
std::vector<float>& rawMagnitudes,
std::vector<float>& smoothedMagnitudes
);
void updateSilentSideMagnitudes();
void updateMagnitudes();
};
-62
View File
@@ -1,62 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace Prism::Capture {
struct Support {
bool available = false;
std::string reason;
};
struct OutputDevice {
std::string id;
std::string label;
double sampleRate = 48000.0;
uint32_t channelCount = 2;
bool isDefault = false;
};
struct StartResult {
double sampleRate = 48000.0;
uint32_t channelCount = 2;
std::string deviceId;
std::string deviceLabel;
};
struct AudioChunk {
std::vector<float> left;
std::vector<float> right;
uint32_t channelCount = 2;
double capturedAtMilliseconds = 0.0;
uint64_t sequence = 0;
};
struct DrainResult {
std::vector<AudioChunk> chunks;
uint64_t overwriteCount = 0;
size_t queueDepth = 0;
};
class SystemAudioCapture {
public:
virtual ~SystemAudioCapture() = default;
virtual Support getSupport() const = 0;
virtual std::vector<OutputDevice> listOutputDevices() = 0;
virtual bool start(const std::string& requestedDeviceId,
StartResult* result,
std::string* errorMessage) = 0;
virtual void stop() = 0;
virtual DrainResult drain(size_t maxChunks = 64) = 0;
virtual double nowMilliseconds() const = 0;
virtual const char* backendName() const = 0;
};
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture();
} // namespace Prism::Capture
-206
View File
@@ -1,206 +0,0 @@
#include "system_audio_capture_napi.h"
#include "system_audio_capture.h"
#include <algorithm>
#include <cstring>
#include <memory>
#include <string>
namespace {
constexpr size_t kDefaultDrainChunkLimit = 64;
constexpr size_t kMaxDrainChunkLimit = 256;
std::unique_ptr<Prism::Capture::SystemAudioCapture>& activeCapture() {
static std::unique_ptr<Prism::Capture::SystemAudioCapture> capture =
Prism::Capture::createSystemAudioCapture();
return capture;
}
const char* activeExportName() {
#if defined(__APPLE__)
return "macosCapture";
#elif defined(_WIN32)
return "windowsCapture";
#else
return "linuxCapture";
#endif
}
Napi::Object supportToNapi(Napi::Env env, const Prism::Capture::Support& support) {
Napi::Object result = Napi::Object::New(env);
result.Set("available", Napi::Boolean::New(env, support.available));
if (support.available || support.reason.empty()) {
result.Set("reason", env.Null());
} else {
result.Set("reason", Napi::String::New(env, support.reason));
}
return result;
}
Napi::Object drainToNapi(Napi::Env env, Prism::Capture::DrainResult&& drained) {
Napi::Array chunks = Napi::Array::New(env, drained.chunks.size());
for (size_t index = 0; index < drained.chunks.size(); ++index) {
auto& chunk = drained.chunks[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set("channelCount", Napi::Number::New(env, chunk.channelCount));
entry.Set("capturedAtMilliseconds", Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set("sequence", Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set("overwriteCount", Napi::Number::New(env, static_cast<double>(drained.overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(drained.queueDepth)));
return result;
}
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
return supportToNapi(info.Env(), activeCapture()->getSupport());
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const auto devices = activeCapture()->listOutputDevices();
Napi::Array result = Napi::Array::New(env, devices.size());
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set("channelCount", Napi::Number::New(env, device.channelCount));
result.Set(static_cast<uint32_t>(index), entry);
}
return result;
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string requestedDeviceId;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
Prism::Capture::StartResult started;
std::string errorMessage;
if (!activeCapture()->start(requestedDeviceId, &started, &errorMessage)) {
Napi::Error::New(env, errorMessage.empty() ? "System audio capture failed to start." : errorMessage)
.ThrowAsJavaScriptException();
return env.Undefined();
}
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, started.sampleRate));
result.Set("channelCount", Napi::Number::New(env, started.channelCount));
result.Set("deviceId", Napi::String::New(env, started.deviceId));
result.Set("deviceLabel", Napi::String::New(env, started.deviceLabel));
return result;
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
activeCapture()->stop();
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = std::min(static_cast<size_t>(requested), kMaxDrainChunkLimit);
}
}
return drainToNapi(info.Env(), activeCapture()->drain(maxChunks));
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), activeCapture()->nowMilliseconds());
}
void RegisterCaptureObject(Napi::Env env, Napi::Object exports, const char* exportName) {
Napi::Object capture = Napi::Object::New(env);
capture.Set("getSupport", Napi::Function::New(env, GetSupport));
capture.Set("listOutputDevices", Napi::Function::New(env, ListOutputDevices));
capture.Set("start", Napi::Function::New(env, Start));
capture.Set("stop", Napi::Function::New(env, Stop));
capture.Set("drain", Napi::Function::New(env, Drain));
capture.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set(exportName, capture);
}
Napi::Value UnavailableGetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(info.Env(), "This system-audio capture backend is unavailable on the current platform."));
return support;
}
Napi::Value UnavailableListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value UnavailableStart(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "This system-audio capture backend is unavailable on the current platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value UnavailableStop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value UnavailableDrain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value UnavailableNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
void RegisterUnavailableCaptureObject(Napi::Env env, Napi::Object exports, const char* exportName) {
Napi::Object capture = Napi::Object::New(env);
capture.Set("getSupport", Napi::Function::New(env, UnavailableGetSupport));
capture.Set("listOutputDevices", Napi::Function::New(env, UnavailableListOutputDevices));
capture.Set("start", Napi::Function::New(env, UnavailableStart));
capture.Set("stop", Napi::Function::New(env, UnavailableStop));
capture.Set("drain", Napi::Function::New(env, UnavailableDrain));
capture.Set("nowMilliseconds", Napi::Function::New(env, UnavailableNowMilliseconds));
exports.Set(exportName, capture);
}
} // namespace
void RegisterSystemAudioCapture(Napi::Env env, Napi::Object exports) {
RegisterCaptureObject(env, exports, activeExportName());
#if !defined(__APPLE__)
RegisterUnavailableCaptureObject(env, exports, "macosCapture");
#endif
#if !defined(_WIN32)
RegisterUnavailableCaptureObject(env, exports, "windowsCapture");
#endif
#if defined(__APPLE__) || defined(_WIN32)
RegisterUnavailableCaptureObject(env, exports, "linuxCapture");
#endif
}
-5
View File
@@ -1,5 +0,0 @@
#pragma once
#include <napi.h>
void RegisterSystemAudioCapture(Napi::Env env, Napi::Object exports);
+26 -3
View File
@@ -22,11 +22,23 @@ Vectorscope::Vectorscope()
highRightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
points_.reserve(1024);
// Cascaded lowpass at 8kHz, Butterworth (Q=0.707)
// Two stages per channel = 4th order = 24 dB/oct rolloff
// Removes HF noise that causes erratic Lissajous motion
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
multibandSplitter_.configure(sampleRate_);
}
void Vectorscope::setSampleRate(float sampleRate) {
sampleRate_ = sampleRate;
// Redesign all filters with new sample rate
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
multibandSplitter_.configure(sampleRate_);
}
@@ -41,8 +53,15 @@ void Vectorscope::pushSamples(
size_t length
) {
for (size_t i = 0; i < length; i++) {
leftBuffer_[writePos_] = leftChannel[i];
rightBuffer_[writePos_] = rightChannel[i];
// Apply cascaded lowpass filtering
float filteredL = leftLowpass1_.process(leftChannel[i]);
filteredL = leftLowpass2_.process(filteredL);
float filteredR = rightLowpass1_.process(rightChannel[i]);
filteredR = rightLowpass2_.process(filteredR);
leftBuffer_[writePos_] = filteredL;
rightBuffer_[writePos_] = filteredR;
writePos_ = (writePos_ + 1) % VECTORSCOPE_BUFFER_SIZE;
if (validSamples_ < VECTORSCOPE_BUFFER_SIZE) {
@@ -109,7 +128,7 @@ const std::vector<VectorscopePoint>& Vectorscope::process(
const float* rightChannel,
size_t length
) {
// Push through the full-band point pipeline
// Push through the filtering pipeline
pushSamples(leftChannel, rightChannel, length);
// Build legacy output from buffer
@@ -138,6 +157,10 @@ void Vectorscope::reset() {
std::fill(midRightBuffer_.begin(), midRightBuffer_.end(), 0.0f);
std::fill(highLeftBuffer_.begin(), highLeftBuffer_.end(), 0.0f);
std::fill(highRightBuffer_.begin(), highRightBuffer_.end(), 0.0f);
leftLowpass1_.reset();
leftLowpass2_.reset();
rightLowpass1_.reset();
rightLowpass2_.reset();
multibandSplitter_.reset();
points_.clear();
}
+7 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include "dsp_utils.h"
#include "multiband.h"
#include <vector>
#include <cstddef>
@@ -51,7 +52,7 @@ private:
size_t writePos_;
size_t validSamples_;
// Circular buffers for full-band L/R
// Circular buffers for filtered L/R
std::vector<float> leftBuffer_;
std::vector<float> rightBuffer_;
std::vector<float> lowLeftBuffer_;
@@ -61,6 +62,11 @@ private:
std::vector<float> highLeftBuffer_;
std::vector<float> highRightBuffer_;
// Cascaded lowpass filters (4th order Butterworth at 8kHz per channel)
DSP::BiquadFilter leftLowpass1_;
DSP::BiquadFilter leftLowpass2_;
DSP::BiquadFilter rightLowpass1_;
DSP::BiquadFilter rightLowpass2_;
MultibandSplitter multibandSplitter_;
size_t multibandWritePos_;
size_t multibandValidSamples_;
-181
View File
@@ -1,181 +0,0 @@
#include "window_chrome.h"
#ifdef _WIN32
#include <windows.h>
#include <dwmapi.h>
// These DWM attributes/values exist on Windows 11 (build 22000+) but may be
// missing from older SDK headers, so provide fallback definitions.
#ifndef DWMWA_NCRENDERING_POLICY
#define DWMWA_NCRENDERING_POLICY 2
#endif
#ifndef DWMNCRP_DISABLED
#define DWMNCRP_DISABLED 1
#endif
#ifndef DWMWA_WINDOW_CORNER_PREFERENCE
#define DWMWA_WINDOW_CORNER_PREFERENCE 33
#endif
#ifndef DWMWA_BORDER_COLOR
#define DWMWA_BORDER_COLOR 34
#endif
#ifndef DWMWCP_DONOTROUND
#define DWMWCP_DONOTROUND 1
#endif
#ifndef DWMWA_COLOR_NONE
#define DWMWA_COLOR_NONE 0xFFFFFFFE
#endif
namespace {
// Undocumented but long-stable user32 API used for accent-policy acrylic.
// Unlike DWMWA_SYSTEMBACKDROP_TYPE acrylic, this blur stays active while the
// window is unfocused and renders on borderless transparent windows, which is
// exactly what an always-visible visualizer overlay needs.
struct AccentPolicy {
int32_t AccentState;
int32_t AccentFlags;
uint32_t GradientColor; // AABBGGRR
int32_t AnimationId;
};
struct WindowCompositionAttribData {
int32_t Attrib;
void* pvData;
SIZE_T cbData;
};
using SetWindowCompositionAttributeFn = BOOL(WINAPI*)(HWND, WindowCompositionAttribData*);
constexpr int32_t kAccentDisabled = 0;
constexpr int32_t kAccentEnableAcrylicBlurBehind = 4;
constexpr int32_t kWcaAccentPolicy = 19;
// A barely-visible tint keeps some Windows builds from optimizing the blur
// away entirely when the requested tint alpha is zero.
constexpr uint32_t kNearTransparentTint = 0x01000000u;
HWND ReadWindowHandle(const Napi::CallbackInfo& info, Napi::Env env) {
if (info.Length() < 1 || !info[0].IsBuffer()) {
Napi::TypeError::New(env, "Expected native window handle Buffer")
.ThrowAsJavaScriptException();
return nullptr;
}
Napi::Buffer<uint8_t> handle = info[0].As<Napi::Buffer<uint8_t>>();
if (handle.Length() < sizeof(HWND)) {
Napi::TypeError::New(env, "Window handle Buffer is too small")
.ThrowAsJavaScriptException();
return nullptr;
}
HWND hwnd = *reinterpret_cast<HWND*>(handle.Data());
if (hwnd == nullptr || !IsWindow(hwnd)) {
return nullptr;
}
return hwnd;
}
Napi::Value SetAcrylicBlurBehind(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
HWND hwnd = ReadWindowHandle(info, env);
if (env.IsExceptionPending() || hwnd == nullptr) {
return env.IsExceptionPending() ? env.Undefined() : Napi::Boolean::New(env, false);
}
const bool enable = info.Length() > 1 && info[1].IsBoolean()
? info[1].As<Napi::Boolean>().Value()
: true;
HMODULE user32 = GetModuleHandleW(L"user32.dll");
if (user32 == nullptr) {
return Napi::Boolean::New(env, false);
}
auto setWindowCompositionAttribute = reinterpret_cast<SetWindowCompositionAttributeFn>(
GetProcAddress(user32, "SetWindowCompositionAttribute"));
if (setWindowCompositionAttribute == nullptr) {
return Napi::Boolean::New(env, false);
}
AccentPolicy policy = {};
policy.AccentState = enable ? kAccentEnableAcrylicBlurBehind : kAccentDisabled;
policy.AccentFlags = 0;
policy.GradientColor = enable ? kNearTransparentTint : 0u;
policy.AnimationId = 0;
WindowCompositionAttribData data = {};
data.Attrib = kWcaAccentPolicy;
data.pvData = &policy;
data.cbData = sizeof(policy);
return Napi::Boolean::New(env, setWindowCompositionAttribute(hwnd, &data) != FALSE);
}
Napi::Value ApplyFlatFrame(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsBuffer()) {
Napi::TypeError::New(env, "Expected native window handle Buffer")
.ThrowAsJavaScriptException();
return env.Undefined();
}
Napi::Buffer<uint8_t> handle = info[0].As<Napi::Buffer<uint8_t>>();
if (handle.Length() < sizeof(HWND)) {
Napi::TypeError::New(env, "Window handle Buffer is too small")
.ThrowAsJavaScriptException();
return env.Undefined();
}
HWND hwnd = *reinterpret_cast<HWND*>(handle.Data());
if (hwnd == nullptr || !IsWindow(hwnd)) {
return Napi::Boolean::New(env, false);
}
// The window keeps WS_THICKFRAME so native Aero Snap and edge resize work, but
// on Windows 11 that style makes DWM paint a visible border and a drop shadow
// ("depth"). Disabling DWM non-client rendering removes that shadow/border,
// and squaring the corners + clearing the accent border color restores the old
// flat, borderless look. All four calls are no-ops/benign on Windows 10 and
// fail harmlessly on unsupported builds.
DWORD ncRendering = DWMNCRP_DISABLED;
DwmSetWindowAttribute(hwnd, DWMWA_NCRENDERING_POLICY, &ncRendering, sizeof(ncRendering));
DWORD cornerPreference = DWMWCP_DONOTROUND;
DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &cornerPreference, sizeof(cornerPreference));
COLORREF borderColor = DWMWA_COLOR_NONE;
DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, &borderColor, sizeof(borderColor));
MARGINS margins = {0, 0, 0, 0};
DwmExtendFrameIntoClientArea(hwnd, &margins);
return Napi::Boolean::New(env, true);
}
} // namespace
void RegisterWindowChrome(Napi::Env env, Napi::Object exports) {
Napi::Object chrome = Napi::Object::New(env);
chrome.Set("applyFlatFrame", Napi::Function::New(env, ApplyFlatFrame));
chrome.Set("setAcrylicBlurBehind", Napi::Function::New(env, SetAcrylicBlurBehind));
exports.Set("windowChrome", chrome);
}
#else // !_WIN32
namespace {
Napi::Value ApplyFlatFrameNoop(const Napi::CallbackInfo& info) {
return Napi::Boolean::New(info.Env(), false);
}
} // namespace
void RegisterWindowChrome(Napi::Env env, Napi::Object exports) {
Napi::Object chrome = Napi::Object::New(env);
chrome.Set("applyFlatFrame", Napi::Function::New(env, ApplyFlatFrameNoop));
chrome.Set("setAcrylicBlurBehind", Napi::Function::New(env, ApplyFlatFrameNoop));
exports.Set("windowChrome", chrome);
}
#endif // _WIN32
-9
View File
@@ -1,9 +0,0 @@
#pragma once
#include <napi.h>
// Registers the `windowChrome` export. On Windows it exposes `applyFlatFrame`,
// which strips the visible native frame/shadow from a frameless WS_THICKFRAME
// window while preserving native Aero Snap and edge resize. On other platforms
// it registers a no-op so the JS surface stays uniform.
void RegisterWindowChrome(Napi::Env env, Napi::Object exports);
+129 -77
View File
@@ -1,7 +1,4 @@
#ifndef PRISM_CAPTURE_CORE_ONLY
#include "windows_capture.h"
#endif
#include "system_audio_capture.h"
#if defined(_WIN32)
@@ -13,16 +10,14 @@
#include <mmreg.h>
#include <propidl.h>
#include <avrt.h>
#include <roapi.h>
#include <wrl/client.h>
#include <windows.h>
#ifndef PRISM_CAPTURE_CORE_ONLY
#include <roapi.h>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Media.Control.h>
#include <winrt/Windows.Storage.Streams.h>
#endif
#include <algorithm>
#include <atomic>
@@ -44,11 +39,9 @@
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;
@@ -138,7 +131,6 @@ 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();
@@ -173,7 +165,6 @@ std::string playbackStatusToString(GlobalSystemMediaTransportControlsSessionPlay
return "Closed";
}
}
#endif
class ScopedCoInit {
public:
@@ -200,7 +191,6 @@ private:
bool usable_;
};
#ifndef PRISM_CAPTURE_CORE_ONLY
class ScopedRoInit {
public:
ScopedRoInit()
@@ -354,7 +344,6 @@ std::string getOrFetchThumbnail(
}
return result;
}
#endif
std::string getDeviceId(IMMDevice* device) {
if (device == nullptr) {
@@ -595,90 +584,114 @@ std::vector<OutputDeviceInfo> enumerateOutputDevices() {
return devices;
}
class WindowsNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
class WindowsNativeCaptureEngine {
public:
~WindowsNativeCaptureEngine() override {
stop();
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;
}
Prism::Capture::Support getSupport() const override {
return {true, {}};
}
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
Napi::Array ListOutputDevices(Napi::Env env) {
const auto devices = enumerateOutputDevices();
std::vector<Prism::Capture::OutputDevice> result;
result.reserve(devices.size());
for (const auto& device : devices) {
result.push_back({
device.id,
device.label,
device.sampleRate,
static_cast<uint32_t>(device.channelCount),
device.isDefault,
});
Napi::Array result = Napi::Array::New(env, devices.size());
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.channelCount)));
result.Set(static_cast<uint32_t>(index), entry);
}
return result;
}
bool start(const std::string& requestedDeviceId,
Prism::Capture::StartResult* result,
std::string* errorMessage) override {
if (!startInternal(requestedDeviceId, errorMessage)) {
return false;
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);
}
if (result != nullptr) {
std::lock_guard<std::mutex> lock(stateMutex_);
result->sampleRate = sampleRate_;
result->channelCount = static_cast<uint32_t>(channelCount_);
result->deviceId = activeDeviceId_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
std::lock_guard<std::mutex> lock(stateMutex_);
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set(
"channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceId_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
}
void stop() override {
void Stop() {
stopInternal();
}
Prism::Capture::DrainResult drain(size_t maxChunks) override {
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
const size_t drainLimit =
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
Prism::Capture::DrainResult result;
uint64_t overwriteCount = 0;
size_t queueDepth = 0;
{
std::lock_guard<std::mutex> lock(chunkMutex_);
result.overwriteCount = overwriteCount_;
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();
queueDepth = chunkQueue_.size();
}
result.chunks.reserve(drained.size());
while (!drained.empty()) {
auto chunk = std::move(drained.front());
drained.pop_front();
result.chunks.push_back({
std::move(chunk.left),
std::move(chunk.right),
static_cast<uint32_t>(chunk.channelCount),
chunk.capturedAtMilliseconds,
chunk.sequence,
});
Napi::Array chunks = Napi::Array::New(env, drained.size());
for (size_t index = 0; index < drained.size(); ++index) {
auto& chunk = drained[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount", Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(queueDepth)));
return result;
}
double nowMilliseconds() const override {
double NowMilliseconds() const {
return monotonicMilliseconds();
}
const char* backendName() const override {
return "WASAPI";
}
private:
bool startInternal(const std::string& requestedDeviceId, std::string* outErrorMessage) {
stopInternal();
@@ -1050,7 +1063,47 @@ private:
UINT32 channelCount_ = 2;
};
#ifndef PRISM_CAPTURE_CORE_ONLY
WindowsNativeCaptureEngine& engine() {
static WindowsNativeCaptureEngine instance;
return instance;
}
Napi::Value WindowsGetSupport(const Napi::CallbackInfo& info) {
return engine().GetSupport(info.Env());
}
Napi::Value WindowsListOutputDevices(const Napi::CallbackInfo& info) {
return engine().ListOutputDevices(info.Env());
}
Napi::Value WindowsStart(const Napi::CallbackInfo& info) {
std::string requestedDeviceId;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
return engine().Start(info.Env(), requestedDeviceId);
}
Napi::Value WindowsStop(const Napi::CallbackInfo& info) {
engine().Stop();
return info.Env().Undefined();
}
Napi::Value WindowsDrain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = static_cast<size_t>(requested);
}
}
return engine().Drain(info.Env(), maxChunks);
}
Napi::Value WindowsNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
}
Napi::Value WindowsMediaGetSupport(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
@@ -1211,12 +1264,20 @@ Napi::Value WindowsMediaSendSpotifyControl(const Napi::CallbackInfo& info) {
return env.Null();
}
}
#endif
} // namespace
#ifndef PRISM_CAPTURE_CORE_ONLY
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
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);
Napi::Object mediaExports = Napi::Object::New(env);
mediaExports.Set("getSupport", Napi::Function::New(env, WindowsMediaGetSupport));
mediaExports.Set(
@@ -1227,14 +1288,5 @@ void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
Napi::Function::New(env, WindowsMediaSendSpotifyControl));
exports.Set("windowsMedia", mediaExports);
}
#endif
namespace Prism::Capture {
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
return std::make_unique<WindowsNativeCaptureEngine>();
}
} // namespace Prism::Capture
#endif // defined(_WIN32)
+1 -1
View File
@@ -2,4 +2,4 @@
#include <napi.h>
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports);
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports);
+48 -1
View File
@@ -2,6 +2,43 @@
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));
@@ -25,7 +62,17 @@ Napi::Value SendSpotifyControl(const Napi::CallbackInfo& info) {
} // namespace
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
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);
Napi::Object mediaExports = Napi::Object::New(env);
mediaExports.Set("getSupport", Napi::Function::New(env, MediaGetSupport));
mediaExports.Set(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "prism",
"version": "0.3.0-beta",
"version": "0.2.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prism",
"version": "0.3.0-beta",
"version": "0.2.0-beta",
"hasInstallScript": true,
"license": "GPL-3.0-only",
"dependencies": {
+7 -30
View File
@@ -1,6 +1,6 @@
{
"name": "prism",
"version": "0.3.0-beta",
"version": "0.2.0-beta",
"description": "Open-source audio metering and visualization tool",
"main": "./out/main/index.js",
"scripts": {
@@ -11,7 +11,6 @@
"typecheck": "tsc --noEmit",
"build:icons": "node scripts/build/generate-prism-icons.mjs",
"test:audio-router": "node scripts/run-audio-router-tests.mjs",
"test:audio-clips": "node scripts/run-audio-clip-tests.mjs",
"test:audio-store": "node scripts/run-audio-store-tests.mjs",
"test:astra": "node scripts/run-astra-integration-tests.mjs",
"test:capture-support": "node scripts/run-capture-support-tests.mjs",
@@ -21,28 +20,20 @@
"test:themes": "node scripts/run-theme-library-tests.mjs",
"test:secret-vault": "node scripts/run-secret-vault-tests.mjs",
"test:window-state": "node scripts/run-window-state-tests.mjs",
"test:desktop-integration": "node scripts/run-desktop-integration-tests.mjs",
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
"test:spectrum-native": "node --test test/spectrum-native.test.mjs",
"test:spectrogram-native": "node --test test/spectrogram-native.test.mjs",
"test:vectorscope-native": "node --test test/vectorscope-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": "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"
"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"
},
"repository": {
"type": "git",
@@ -104,21 +95,9 @@
"from": "resources/icon.png",
"to": "icon.png"
},
{
"from": "resources/tray/",
"to": "tray/"
},
{
"from": "plugin/dist-installer/",
"to": "plugins/"
},
{
"from": "tui/dist-installer/",
"to": "tui/"
},
{
"from": "THIRD_PARTY_NOTICES.md",
"to": "THIRD_PARTY_NOTICES.md"
}
],
"mac": {
@@ -164,7 +143,6 @@
"libatspi2.0-0",
"libuuid1",
"libsecret-1-0",
"libpulse0",
"libwebkit2gtk-4.1-0"
]
},
@@ -181,7 +159,6 @@
"at-spi2-core",
"libuuid",
"libsecret",
"pulseaudio-libs",
"webkit2gtk4.1"
]
}
-6
View File
@@ -277,7 +277,6 @@ namespace
.withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); })
.withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); })
.withEventListener("prismSpectrogramConfig", [&editor](juce::var v) { editor.onScopeNativeConfig(std::move(v)); })
.withEventListener("prismScopeMeasurement", [&editor](juce::var v) { editor.onScopeMeasurement(std::move(v)); })
.withEventListener("prismSettingsPanel", [&editor](juce::var v) { editor.onSettingsPanel(std::move(v)); });
#if JUCE_WINDOWS
@@ -606,11 +605,6 @@ void PrismSpectrumEditor::onScopeNativeConfig(juce::var payload)
engine->configureNative(payload);
}
void PrismSpectrumEditor::onScopeMeasurement(juce::var payload)
{
engine->setMeasurementActive((bool) payload.getProperty("active", false));
}
void PrismSpectrumEditor::pushRestoreSettings()
{
if (webView == nullptr)
-3
View File
@@ -36,9 +36,6 @@ public:
// Scope-specific native config (e.g. the spectrogram's canvas-derived rowCount).
void onScopeNativeConfig(juce::var payload);
// Momentary crosshair state; Spectrum uses it for temporary DSP smoothing.
void onScopeMeasurement(juce::var payload);
// The UI's settings panel opened/closed along the bottom. Grow/shrink the editor
// height by exactly the panel height so the scope area is unchanged (the window
// accommodates the panel, like the app). 0 = closed.
-3
View File
@@ -41,9 +41,6 @@ public:
*/
virtual void configureNative(const juce::var&) {}
/** Temporary UI interaction state. Most scopes do not need DSP changes. */
virtual void setMeasurementActive(bool) {}
/** Feed audio (called off the realtime thread). numSamples may be 0. */
virtual void process(const float* left, const float* right, int numSamples) = 0;
+8 -2
View File
@@ -10,7 +10,7 @@
* produces finished display+heat columns. Unlike the other scopes, its DSP needs
* the canvas-derived rowCount (and fft/freq/db/scale/orientation), which only the
* webview knows — the UI pushes the full native config via "prismSpectrogramConfig",
* routed here through configureNative(). process() preserves stereo energy in the DSP;
* routed here through configureNative(). process() mixes to mono and runs the DSP;
* buildFrame() emits the columns produced since the last frame (base64) tagged with
* rowCount, so the bridge can match them to the config the UI currently expects.
* configure() (scope settings) is a no-op — every DSP parameter arrives in the
@@ -75,7 +75,12 @@ public:
{
if (! hasConfig || numSamples <= 0)
return;
auto result = spectro.processStereo(left, right, (size_t) numSamples);
if ((int) mono.size() < numSamples)
mono.resize((size_t) numSamples);
for (int i = 0; i < numSamples; ++i)
mono[(size_t) i] = 0.5f * (left[i] + right[i]);
auto result = spectro.process(mono.data(), (size_t) numSamples);
if (result.columnCount > 0 && result.rowCount == config.rowCount)
{
pendingDisplay.insert(pendingDisplay.end(), result.display.begin(), result.display.end());
@@ -111,6 +116,7 @@ private:
Visualizer::SpectrogramAnalyzer spectro;
Visualizer::SpectrogramConfig config;
bool hasConfig = false;
std::vector<float> mono;
std::vector<float> pendingDisplay, pendingHeat;
size_t pendingColumns = 0;
};
+1 -23
View File
@@ -22,18 +22,7 @@ public:
if (fftSize > 0 && (size_t) fftSize != spectrum.getFFTSize())
spectrum.setFFTSize((size_t) fftSize);
configuredSmoothing = juce::jlimit(0.0f, 0.99f,
(float) (double) settings.getProperty("smoothing", 0.9));
applySmoothing();
}
void setMeasurementActive(bool active) override
{
if (measurementActive == active)
return;
measurementActive = active;
applySmoothing();
spectrum.setSmoothing((float) (double) settings.getProperty("smoothing", 0.9));
}
void process(const float* left, const float* right, int numSamples) override
@@ -50,19 +39,10 @@ public:
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("magnitudes", toBase64(spectrum.getMagnitudes()));
obj->setProperty("side", toBase64(spectrum.getSideMagnitudes()));
obj->setProperty("channelMax", toBase64(spectrum.getChannelMaxMagnitudes()));
return juce::var(obj);
}
private:
void applySmoothing()
{
constexpr float measurementSmoothing = 0.97f;
spectrum.setSmoothing(measurementActive
? juce::jmax(configuredSmoothing, measurementSmoothing)
: configuredSmoothing);
}
static juce::String toBase64(const std::vector<float>& data)
{
return data.empty() ? juce::String()
@@ -71,6 +51,4 @@ private:
const juce::Identifier frameId { "spectrumFrame" };
Visualizer::Spectrum spectrum { 2048 };
float configuredSmoothing = 0.9f;
bool measurementActive = false;
};
+1 -1
View File
@@ -6,7 +6,7 @@
/**
* Vectorscope engine. Pushes stereo audio into the reused `Visualizer::Vectorscope`
* (full-band L/R + a 3-band split, both in circular buffers) and emits the
* (lowpass-filtered L/R + a 3-band split, both in circular buffers) and emits the
* most recent display points each frame. Two layouts share the buffers: the standard
* X/Y point cloud and the multiband (low/mid/high) cloud. Both buffers are kept warm
* so toggling is instant; the active layout (from the `multiband` setting) is flagged
@@ -1,51 +1,11 @@
#!/bin/sh
# Package post-install hook for Linux .deb/.rpm builds. The app package installs
# 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.
# Prism under /opt, then this 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
@@ -80,8 +40,6 @@ install_plugin() {
cp -a "$source_plugin" "$DEST_DIR/"
}
install_tui
SOURCE_DIR="$(find_source_dir || true)"
if [ -z "$SOURCE_DIR" ]; then
echo "Prism VST3 install: bundled VST3 directory not found; skipping plugin install." >&2
@@ -4,42 +4,7 @@
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"
@@ -52,6 +17,5 @@ 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"
+3 -19
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Postinstall: after the macOS Installer drops Prism.app into /Applications, copy
# the bundled VST3 / AU plugins out of the app's Resources into the system
# Audio Plug-Ins folders so DAWs find them, and expose prism-tui on PATH.
# Audio Plug-Ins folders so DAWs find them.
#
# Standard pkg postinstall args:
# $1 = full path to the installer package
@@ -17,10 +17,8 @@ APP_PATH="${2:-/Applications}/Prism.app"
[ -d "$APP_PATH" ] || APP_PATH="/Applications/Prism.app"
PLUGINS_SRC="$APP_PATH/Contents/Resources/plugins"
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}"
VST3_DEST="/Library/Audio/Plug-Ins/VST3"
AU_DEST="/Library/Audio/Plug-Ins/Components"
mkdir -p "$VST3_DEST" "$AU_DEST"
@@ -31,18 +29,4 @@ 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
+1 -32
View File
@@ -11,11 +11,9 @@
; 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
@@ -55,25 +53,6 @@
!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"
@@ -100,16 +79,6 @@
!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"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 B

-49
View File
@@ -1,49 +0,0 @@
const { copyFileSync, existsSync, mkdirSync, chmodSync } = require('node:fs')
const { join } = require('node:path')
const { spawnSync } = require('node:child_process')
const rootDir = join(__dirname, '..', '..')
const buildDir = join(rootDir, 'tui', 'build')
const packageJson = require(join(rootDir, 'package.json'))
const shouldTest = process.argv.includes('--test')
const shouldStage = process.argv.includes('--stage')
const configureOnly = process.argv.includes('--configure-only')
function run(command, args) {
const result = spawnSync(command, args, { cwd: rootDir, stdio: 'inherit' })
if (result.status !== 0) process.exit(result.status ?? 1)
}
run('cmake', [
'-S', 'tui',
'-B', 'tui/build',
'-DCMAKE_BUILD_TYPE=Release',
`-DPRISM_VERSION=${packageJson.version}`,
])
if (configureOnly) process.exit(0)
run('cmake', ['--build', 'tui/build', '--config', 'Release', '--parallel', '4'])
if (shouldTest) {
run('ctest', ['--test-dir', 'tui/build', '-C', 'Release', '--output-on-failure'])
}
if (shouldStage) {
const executableName = process.platform === 'win32' ? 'prism-tui.exe' : 'prism-tui'
const candidates = [
join(buildDir, 'bin', executableName),
join(buildDir, 'bin', 'Release', executableName),
join(buildDir, 'Release', executableName),
]
const source = candidates.find(existsSync)
if (!source) {
console.error(`Could not find built ${executableName}. Checked:\n${candidates.join('\n')}`)
process.exit(1)
}
const stageDir = join(rootDir, 'tui', 'dist-installer')
mkdirSync(stageDir, { recursive: true })
const destination = join(stageDir, executableName)
copyFileSync(source, destination)
if (process.platform !== 'win32') chmodSync(destination, 0o755)
console.log(`Staged ${destination}`)
}
-17
View File
@@ -1,17 +0,0 @@
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)
-64
View File
@@ -16,7 +16,6 @@ import { deflateSync, inflateSync } from 'node:zlib'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(scriptDir, '../..')
const resourcesDir = join(repoRoot, 'resources')
const trayResourcesDir = join(resourcesDir, 'tray')
const tempDir = mkdtempSync(join(tmpdir(), 'prism-icons-'))
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const iconBackground = { r: 5, g: 7, b: 10 }
@@ -241,28 +240,6 @@ function restoreIconBackgroundAlpha(filePath) {
writePngRgba(filePath, width, height, pixels)
}
function convertTemplateToAlphaMask(filePath) {
const image = readPngRgba(filePath)
const { width, height, pixels } = image
for (let offset = 0; offset < pixels.length; offset += 4) {
const originalAlpha = pixels[offset + 3]
const luminance = Math.round(
(pixels[offset] + pixels[offset + 1] + pixels[offset + 2]) / 3,
)
// Quick Look rasterizes SVG previews over white on some macOS versions.
// Template images need that white canvas removed so macOS can tint only
// the Prism mark for light/dark menu bars.
pixels[offset] = 0
pixels[offset + 1] = 0
pixels[offset + 2] = 0
pixels[offset + 3] = Math.round(originalAlpha * ((255 - luminance) / 255))
}
writePngRgba(filePath, width, height, pixels)
}
function writeIcoFile(outputPath, images) {
const headerLength = 6
const entryLength = 16
@@ -355,47 +332,6 @@ try {
icoImages.push({ size, buffer: readFileSync(outputPath) })
}
writeIcoFile(join(resourcesDir, 'icon.ico'), icoImages)
mkdirSync(trayResourcesDir, { recursive: true })
const trayTemplateSvgPath = join(tempDir, 'prism-tray-template-source.svg')
writeFileSync(trayTemplateSvgPath, `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<g transform="translate(${symbolTranslateX} ${symbolTranslateY}) scale(${iconSymbolScale})">
<path d="${lowerPath}" fill="#000000" />
<path d="${upperPath}" fill="#000000" />
</g>
</svg>
`)
execFileSync('qlmanage', ['-t', '-s', '1024', '-o', tempDir, trayTemplateSvgPath], {
stdio: 'ignore',
})
const trayTemplateMaster = `${trayTemplateSvgPath}.png`
if (!existsSync(trayTemplateMaster)) {
throw new Error('Quick Look did not create the tray template PNG.')
}
const trayTemplatePath = join(trayResourcesDir, 'prismTrayTemplate.png')
const trayTemplate2xPath = join(trayResourcesDir, 'prismTrayTemplate@2x.png')
resizePng(trayTemplateMaster, trayTemplatePath, 16)
resizePng(trayTemplateMaster, trayTemplate2xPath, 32)
convertTemplateToAlphaMask(trayTemplatePath)
convertTemplateToAlphaMask(trayTemplate2xPath)
execFileSync('sips', ['-s', 'dpiWidth', '72', '-s', 'dpiHeight', '72', trayTemplatePath], {
stdio: 'ignore',
})
execFileSync('sips', ['-s', 'dpiWidth', '144', '-s', 'dpiHeight', '144', trayTemplate2xPath], {
stdio: 'ignore',
})
resizePng(resourcePngPath, join(trayResourcesDir, 'prism-tray.png'), 24)
const trayIcoImages = []
for (const size of [16, 20, 24, 32, 40, 48]) {
const outputPath = join(tempDir, `prism-tray-${size}.png`)
resizePng(resourcePngPath, outputPath, size)
trayIcoImages.push({ size, buffer: readFileSync(outputPath) })
}
writeIcoFile(join(trayResourcesDir, 'prism-tray.ico'), trayIcoImages)
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
-44
View File
@@ -1,44 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { build } from 'esbuild'
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
const tempDir = await mkdtemp(join(tmpdir(), 'prism-audio-clip-tests-'))
const bundledTestPath = join(tempDir, 'audio-clip-library.test.mjs')
const entryPoint = join(rootDir, 'test', 'audio-clip-library.test.ts')
let exitCode = 1
try {
await build({
entryPoints: [entryPoint],
outfile: bundledTestPath,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node23',
sourcemap: 'inline',
})
exitCode = await new Promise((resolve) => {
const child = spawn(process.execPath, ['--test', bundledTestPath], {
stdio: 'inherit',
cwd: rootDir,
})
child.on('exit', (code) => {
resolve(code ?? 1)
})
child.on('error', () => {
resolve(1)
})
})
} finally {
await rm(tempDir, { recursive: true, force: true })
}
process.exit(exitCode)
-38
View File
@@ -1,38 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { build } from 'esbuild'
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
const tempDir = await mkdtemp(join(tmpdir(), 'prism-desktop-integration-tests-'))
const bundledTestPath = join(tempDir, 'desktop-integration.test.mjs')
const entryPoint = join(rootDir, 'test', 'desktop-integration.test.ts')
let exitCode = 1
try {
await build({
entryPoints: [entryPoint],
outfile: bundledTestPath,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node23',
sourcemap: 'inline',
})
exitCode = await new Promise((resolve) => {
const child = spawn(process.execPath, ['--test', bundledTestPath], {
stdio: 'inherit',
cwd: rootDir,
})
child.on('exit', (code) => resolve(code ?? 1))
child.on('error', () => resolve(1))
})
} finally {
await rm(tempDir, { recursive: true, force: true })
}
process.exit(exitCode)
-113
View File
@@ -1,113 +0,0 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { AudioClipDragPayload } from '../types/audioClip'
const WAV_HEADER_BYTES = 44
const PCM_BITS_PER_SAMPLE = 16
const MAX_SAMPLE_RATE = 384000
const MAX_CLIP_SECONDS = 60
export function validateAudioClipDragPayload(raw: unknown): AudioClipDragPayload {
if (typeof raw !== 'object' || raw === null) {
throw new Error('The audio clip payload is invalid.')
}
const candidate = raw as Partial<AudioClipDragPayload>
if (!(candidate.pcmBytes instanceof Uint8Array)) {
throw new Error('The audio clip is missing PCM sample data.')
}
if (
!Number.isSafeInteger(candidate.sampleRate)
|| candidate.sampleRate! < 1
|| candidate.sampleRate! > MAX_SAMPLE_RATE
) {
throw new Error('The audio clip sample rate is invalid.')
}
if (candidate.channelCount !== 1 && candidate.channelCount !== 2) {
throw new Error('The audio clip channel count is invalid.')
}
if (
!Number.isSafeInteger(candidate.frameCount)
|| candidate.frameCount! < 1
|| candidate.frameCount! > candidate.sampleRate! * MAX_CLIP_SECONDS
) {
throw new Error('The audio clip duration is invalid.')
}
const expectedBytes = candidate.frameCount! * candidate.channelCount * (PCM_BITS_PER_SAMPLE / 8)
if (candidate.pcmBytes.byteLength !== expectedBytes) {
throw new Error('The audio clip PCM data length is invalid.')
}
return {
pcmBytes: candidate.pcmBytes,
sampleRate: candidate.sampleRate!,
channelCount: candidate.channelCount,
frameCount: candidate.frameCount!,
}
}
export function encodePcm16Wav(payload: AudioClipDragPayload): Buffer {
const validated = validateAudioClipDragPayload(payload)
const dataBytes = validated.pcmBytes.byteLength
const blockAlign = validated.channelCount * (PCM_BITS_PER_SAMPLE / 8)
const wav = Buffer.allocUnsafe(WAV_HEADER_BYTES + dataBytes)
wav.write('RIFF', 0, 'ascii')
wav.writeUInt32LE(36 + dataBytes, 4)
wav.write('WAVE', 8, 'ascii')
wav.write('fmt ', 12, 'ascii')
wav.writeUInt32LE(16, 16)
wav.writeUInt16LE(1, 20)
wav.writeUInt16LE(validated.channelCount, 22)
wav.writeUInt32LE(validated.sampleRate, 24)
wav.writeUInt32LE(validated.sampleRate * blockAlign, 28)
wav.writeUInt16LE(blockAlign, 32)
wav.writeUInt16LE(PCM_BITS_PER_SAMPLE, 34)
wav.write('data', 36, 'ascii')
wav.writeUInt32LE(dataBytes, 40)
wav.set(validated.pcmBytes, WAV_HEADER_BYTES)
return wav
}
export function buildAudioClipBaseName(date: Date): string {
const timestamp = date.toISOString()
.replace('T', ' ')
.replace(/:/g, '-')
.replace('Z', '')
return `Prism Clip ${timestamp}`
}
export class AudioClipLibrary {
constructor(
private readonly directory: string,
private readonly now: () => Date = () => new Date(),
) {}
getDirectory(): string {
return this.directory
}
ensureDirectory(): string {
mkdirSync(this.directory, { recursive: true })
return this.directory
}
writeClip(raw: unknown): string {
const payload = validateAudioClipDragPayload(raw)
const wav = encodePcm16Wav(payload)
this.ensureDirectory()
const baseName = buildAudioClipBaseName(this.now())
let suffix = 1
let filePath = join(this.directory, `${baseName}.wav`)
while (existsSync(filePath)) {
suffix += 1
filePath = join(this.directory, `${baseName} (${suffix}).wav`)
}
writeFileSync(filePath, wav, { flag: 'wx' })
return filePath
}
}
+74 -872
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type { NativeWindowChromeAPI } from '../types/nativeWindowChrome'
type NativeAddonModule = VisualizerDSP & NativeCaptureAPI & {
windowChrome?: NativeWindowChromeAPI
}
const require = createRequire(import.meta.url)
const currentDir = dirname(fileURLToPath(import.meta.url))
export function loadNativeWindowChromeApi(): NativeWindowChromeAPI | null {
if (process.platform !== 'win32') {
return null
}
const candidatePaths = [
join(currentDir, '../../native/build/Release/visualizer_dsp.node'),
process.resourcesPath ? join(process.resourcesPath, 'native/visualizer_dsp.node') : null,
].filter((value): value is string => value !== null)
for (const modulePath of candidatePaths) {
try {
const nativeAddon = require(modulePath) as NativeAddonModule
if (nativeAddon.windowChrome) {
return nativeAddon.windowChrome
}
} catch {
// Try the next candidate path.
}
}
return null
}
+1 -6
View File
@@ -351,12 +351,7 @@ export class FileBackedProfileLibrary {
throw new Error(`Unsupported profile format in ${basename(filePath)}.`)
}
if (
candidate.version !== 1
&& candidate.version !== 2
&& candidate.version !== 3
&& candidate.version !== PROFILE_FILE_VERSION
) {
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== PROFILE_FILE_VERSION) {
throw new Error(`Unsupported profile version in ${basename(filePath)}.`)
}
@@ -1,64 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import {
DEFAULT_DESKTOP_INTEGRATION_PREFERENCES,
type DesktopIntegrationPreferences,
} from '../../types/desktopIntegration'
export function normalizeDesktopIntegrationPreferences(value: unknown): DesktopIntegrationPreferences {
if (!value || typeof value !== 'object') {
return { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES }
}
const candidate = value as Partial<DesktopIntegrationPreferences>
return {
closeToTray: candidate.closeToTray === true,
loginLaunchMode: candidate.loginLaunchMode === 'tray' ? 'tray' : 'show',
}
}
export async function loadDesktopIntegrationPreferences(
filePath: string,
): Promise<DesktopIntegrationPreferences> {
try {
return normalizeDesktopIntegrationPreferences(JSON.parse(await readFile(filePath, 'utf8')))
} catch {
return { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES }
}
}
export async function saveDesktopIntegrationPreferences(
filePath: string,
preferences: DesktopIntegrationPreferences,
): Promise<DesktopIntegrationPreferences> {
const normalized = normalizeDesktopIntegrationPreferences(preferences)
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
export type MainWindowCloseDisposition = 'hide-to-tray' | 'close'
export function resolveMainWindowCloseDisposition(options: {
closeToTray: boolean
isAppQuitting: boolean
trayAvailable: boolean
windowRecreationPending: boolean
}): MainWindowCloseDisposition {
return options.closeToTray
&& !options.isAppQuitting
&& !options.windowRecreationPending
&& options.trayAvailable
? 'hide-to-tray'
: 'close'
}
export function resolveStartHiddenAtLogin(options: {
isLoginLaunch: boolean
loginLaunchMode: DesktopIntegrationPreferences['loginLaunchMode']
hasPendingFileOpen: boolean
}): boolean {
return options.isLoginLaunch
&& options.loginLaunchMode === 'tray'
&& !options.hasPendingFileOpen
}
-224
View File
@@ -1,224 +0,0 @@
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join } from 'node:path'
import type {
DesktopIntegrationPreferences,
DesktopIntegrationSnapshot,
LoginItemStatus,
} from '../../types/desktopIntegration'
export const LOGIN_LAUNCH_ARG = '--prism-login-launch'
const LINUX_AUTOSTART_FILENAME = 'com.astra.prism.desktop'
interface NativeLoginItemSettings {
openAtLogin?: boolean
wasOpenedAtLogin?: boolean
status?: 'not-registered' | 'enabled' | 'requires-approval' | 'not-found'
executableWillLaunchAtLogin?: boolean
}
interface LoginItemServiceOptions {
platform: NodeJS.Platform
isPackaged: boolean
executablePath: string
appImagePath?: string
configHome?: string
homePath: string
getNativeSettings?: (options?: { path?: string; args?: string[] }) => NativeLoginItemSettings
setNativeSettings?: (settings: {
openAtLogin: boolean
path?: string
args?: string[]
type?: 'mainAppService'
}) => void
}
function getErrorMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : 'Login item operation failed.'
}
export function resolveLinuxAutostartPath(configHome: string | undefined, homePath: string): string {
const resolvedConfigHome = configHome && isAbsolute(configHome)
? configHome
: join(homePath, '.config')
return join(resolvedConfigHome, 'autostart', LINUX_AUTOSTART_FILENAME)
}
export function resolveLinuxLaunchExecutable(appImagePath: string | undefined, executablePath: string): string {
return appImagePath && isAbsolute(appImagePath) ? appImagePath : executablePath
}
export function quoteDesktopExecArgument(value: string): string {
const escaped = value.replace(/[\\"`$]/g, (character) => `\\${character}`)
return `"${escaped}"`
}
export function buildLinuxAutostartEntry(executablePath: string): string {
const executable = quoteDesktopExecArgument(executablePath)
return [
'[Desktop Entry]',
'Type=Application',
'Version=1.0',
'Name=Prism',
'Comment=Open Prism at login',
`TryExec=${executable}`,
`Exec=${executable} ${LOGIN_LAUNCH_ARG}`,
'Terminal=false',
'Hidden=false',
'X-GNOME-Autostart-enabled=true',
'',
].join('\n')
}
export function resolveNativeLoginItemStatus(
platform: NodeJS.Platform,
settings: NativeLoginItemSettings,
): LoginItemStatus {
if (platform === 'darwin') {
if (settings.status === 'requires-approval') return 'requires-approval'
return settings.openAtLogin || settings.status === 'enabled' ? 'enabled' : 'disabled'
}
if (platform === 'win32') {
if (!settings.openAtLogin) return 'disabled'
return settings.executableWillLaunchAtLogin === false ? 'blocked' : 'enabled'
}
return 'unavailable'
}
export function isLoginLaunch(platform: NodeJS.Platform, argv: string[], nativeSettings?: NativeLoginItemSettings): boolean {
return platform === 'darwin'
? nativeSettings?.wasOpenedAtLogin === true
: argv.includes(LOGIN_LAUNCH_ARG)
}
export class LoginItemService {
constructor(private readonly options: LoginItemServiceOptions) {}
isSupported(): boolean {
return this.options.isPackaged
&& (this.options.platform === 'darwin'
|| this.options.platform === 'win32'
|| this.options.platform === 'linux')
}
wasOpenedAtLogin(argv: string[]): boolean {
if (!this.isSupported()) return false
try {
return isLoginLaunch(
this.options.platform,
argv,
this.options.platform === 'darwin' ? this.options.getNativeSettings?.() : undefined,
)
} catch {
return false
}
}
async getSnapshot(
preferences: DesktopIntegrationPreferences,
): Promise<DesktopIntegrationSnapshot> {
if (!this.isSupported()) {
return {
...preferences,
openAtLogin: false,
loginItemStatus: 'unavailable',
loginItemError: null,
}
}
try {
if (this.options.platform === 'linux') {
const filePath = resolveLinuxAutostartPath(this.options.configHome, this.options.homePath)
let contents = ''
try {
contents = await readFile(filePath, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return {
...preferences,
openAtLogin: false,
loginItemStatus: 'disabled',
loginItemError: null,
}
}
const enabled = !/^\s*Hidden\s*=\s*true\s*$/im.test(contents)
return {
...preferences,
openAtLogin: enabled,
loginItemStatus: enabled ? 'enabled' : 'disabled',
loginItemError: null,
}
}
const nativeOptions = this.options.platform === 'win32'
? { path: this.options.executablePath, args: [LOGIN_LAUNCH_ARG] }
: undefined
const settings = this.options.getNativeSettings?.(nativeOptions) ?? {}
const loginItemStatus = resolveNativeLoginItemStatus(this.options.platform, settings)
return {
...preferences,
openAtLogin: settings.openAtLogin === true
|| loginItemStatus === 'enabled'
|| loginItemStatus === 'requires-approval'
|| loginItemStatus === 'blocked',
loginItemStatus,
loginItemError: null,
}
} catch (error) {
return {
...preferences,
openAtLogin: false,
loginItemStatus: 'error',
loginItemError: getErrorMessage(error),
}
}
}
async setOpenAtLogin(
enabled: boolean,
preferences: DesktopIntegrationPreferences,
): Promise<DesktopIntegrationSnapshot> {
if (!this.isSupported()) {
return this.getSnapshot(preferences)
}
try {
if (this.options.platform === 'linux') {
const filePath = resolveLinuxAutostartPath(this.options.configHome, this.options.homePath)
if (enabled) {
const executablePath = resolveLinuxLaunchExecutable(
this.options.appImagePath,
this.options.executablePath,
)
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, buildLinuxAutostartEntry(executablePath), 'utf8')
} else {
await unlink(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error
})
}
} else if (this.options.platform === 'win32') {
this.options.setNativeSettings?.({
openAtLogin: enabled,
path: this.options.executablePath,
args: [LOGIN_LAUNCH_ARG],
})
} else {
this.options.setNativeSettings?.({
openAtLogin: enabled,
type: 'mainAppService',
})
}
} catch (error) {
const current = await this.getSnapshot(preferences)
return {
...current,
loginItemStatus: 'error',
loginItemError: getErrorMessage(error),
}
}
return this.getSnapshot(preferences)
}
}
-19
View File
@@ -1,19 +0,0 @@
import { join } from 'node:path'
export function getTrayAssetFilename(platform: NodeJS.Platform): string {
if (platform === 'darwin') return 'prismTrayTemplate.png'
if (platform === 'win32') return 'prism-tray.ico'
return 'prism-tray.png'
}
export function resolveTrayAssetPath(options: {
platform: NodeJS.Platform
isPackaged: boolean
resourcesPath: string
appPath: string
}): string {
const directory = options.isPackaged
? join(options.resourcesPath, 'tray')
: join(options.appPath, 'resources', 'tray')
return join(directory, getTrayAssetFilename(options.platform))
}
-126
View File
@@ -1,126 +0,0 @@
import {
DEFAULT_TRAY_RENDERER_STATE,
type DesktopIntegrationSnapshot,
type TrayAudioSourceOption,
type TrayProfileOption,
type TrayRendererState,
} from '../../types/desktopIntegration'
import { isRollingCaptureDuration } from '../../types/audioClip'
const MAX_TRAY_ITEMS = 64
const MAX_LABEL_LENGTH = 96
export interface TrayMenuState {
mainWindowVisible: boolean
rendererReady: boolean
rendererState: TrayRendererState
desktopIntegration: DesktopIntegrationSnapshot
alwaysOnTop: boolean
supportsReposition: boolean
}
export interface TrayMenuModel extends TrayMenuState {
statusLabel: string
tooltip: string
mainWindowActionLabel: 'Show Prism' | 'Hide Prism'
captureActionLabel: 'Start Capture' | 'Stop Capture'
captureActionEnabled: boolean
}
function normalizeText(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback
const trimmed = value.trim()
if (!trimmed) return fallback
return trimmed.slice(0, MAX_LABEL_LENGTH)
}
function normalizeProfiles(value: unknown): TrayProfileOption[] {
if (!Array.isArray(value)) return []
return value.slice(0, MAX_TRAY_ITEMS).flatMap((raw) => {
if (!raw || typeof raw !== 'object') return []
const candidate = raw as Partial<TrayProfileOption>
if (typeof candidate.id !== 'string' || !candidate.id.trim()) return []
return [{
id: candidate.id,
name: normalizeText(candidate.name, 'Profile'),
}]
})
}
function normalizeSources(value: unknown): TrayAudioSourceOption[] {
if (!Array.isArray(value)) return []
return value.slice(0, MAX_TRAY_ITEMS).flatMap((raw) => {
if (!raw || typeof raw !== 'object') return []
const candidate = raw as Partial<TrayAudioSourceOption>
if (typeof candidate.id !== 'string') return []
return [{
id: candidate.id,
label: normalizeText(candidate.label, 'Audio Source'),
isDefault: candidate.isDefault === true,
}]
})
}
export function normalizeTrayRendererState(value: unknown): TrayRendererState {
if (!value || typeof value !== 'object') {
return { ...DEFAULT_TRAY_RENDERER_STATE }
}
const candidate = value as Partial<TrayRendererState>
const captureStatus = candidate.captureStatus === 'connecting'
|| candidate.captureStatus === 'capturing'
|| candidate.captureStatus === 'error'
? candidate.captureStatus
: 'idle'
return {
profiles: normalizeProfiles(candidate.profiles),
activeProfileId: typeof candidate.activeProfileId === 'string' ? candidate.activeProfileId : null,
hasUnsavedProfileChanges: candidate.hasUnsavedProfileChanges === true,
captureStatus,
activeSourceLabel: typeof candidate.activeSourceLabel === 'string'
? normalizeText(candidate.activeSourceLabel, 'Audio Source')
: null,
captureMode: candidate.captureMode === 'device' ? 'device' : 'system',
selectedSystemSourceId: typeof candidate.selectedSystemSourceId === 'string'
? candidate.selectedSystemSourceId
: null,
selectedDeviceId: typeof candidate.selectedDeviceId === 'string'
? candidate.selectedDeviceId
: null,
rollingCaptureSeconds: isRollingCaptureDuration(candidate.rollingCaptureSeconds)
? candidate.rollingCaptureSeconds
: null,
systemSources: normalizeSources(candidate.systemSources),
inputSources: normalizeSources(candidate.inputSources),
}
}
export function buildTrayMenuModel(state: TrayMenuState): TrayMenuModel {
const sourceLabel = state.rendererState.activeSourceLabel
const statusText = state.rendererState.captureStatus === 'capturing'
? sourceLabel ? `Capturing · ${sourceLabel}` : 'Capturing'
: state.rendererState.captureStatus === 'connecting'
? 'Connecting to audio…'
: state.rendererState.captureStatus === 'error'
? 'Audio capture error'
: 'Capture stopped'
return {
...state,
statusLabel: `Prism — ${statusText}`,
tooltip: sourceLabel && state.rendererState.captureStatus === 'capturing'
? `Prism — ${sourceLabel}`
: 'Prism',
mainWindowActionLabel: state.mainWindowVisible ? 'Hide Prism' : 'Show Prism',
captureActionLabel: state.rendererState.captureStatus === 'capturing'
|| state.rendererState.captureStatus === 'connecting'
? 'Stop Capture'
: 'Start Capture',
captureActionEnabled: state.rendererReady,
}
}
export function createTrayMenuStateKey(model: TrayMenuModel): string {
return JSON.stringify(model)
}
@@ -1,20 +0,0 @@
import type { TrayRendererCommand } from '../../types/desktopIntegration'
export class TrayRendererCommandQueue {
private commands: TrayRendererCommand[] = []
enqueue(command: TrayRendererCommand): void {
this.commands.push(command)
}
flush(send: (command: TrayRendererCommand) => void): void {
for (const command of this.commands) {
send(command)
}
this.commands = []
}
clear(): void {
this.commands = []
}
}
+1 -15
View File
@@ -3,7 +3,7 @@ import { dirname } from 'node:path'
import { createEmptyWindowLocalState, normalizeWindowLocalState } from '../shared/windowState'
import type { WindowBounds } from '../types/popout'
import type { ScopeKind } from '../types/scope'
import type { PrismWindowLocalStateV1, WindowBackgroundState } from '../types/windowState'
import type { PrismWindowLocalStateV1 } from '../types/windowState'
export class FileBackedWindowStateStore {
private state: PrismWindowLocalStateV1 = createEmptyWindowLocalState()
@@ -26,10 +26,6 @@ export class FileBackedWindowStateStore {
return this.state.popoutAlwaysOnTop[kind] === true
}
getWindowBackground(): WindowBackgroundState {
return { ...this.state.windowBackground }
}
getNowPlayingConfigWindowBounds(): WindowBounds | undefined {
return this.state.nowPlayingConfigWindowBounds
? { ...this.state.nowPlayingConfigWindowBounds }
@@ -63,16 +59,6 @@ export class FileBackedWindowStateStore {
await this.persistState()
}
async setWindowBackground(background: WindowBackgroundState): Promise<void> {
await this.ensureInitialized()
this.state = normalizeWindowLocalState({
...this.state,
windowBackground: background,
popoutAlwaysOnTop: { ...this.state.popoutAlwaysOnTop },
})
await this.persistState()
}
async setNowPlayingConfigWindowBounds(bounds?: WindowBounds): Promise<void> {
await this.ensureInitialized()
this.state = normalizeWindowLocalState({
+1 -23
View File
@@ -20,17 +20,15 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
private sampleRate = 48000
private magnitudes: Float32Array
private sideMagnitudes: Float32Array
private channelMaxMagnitudes: Float32Array
constructor(fftSize = 2048) {
this.fftSize = fftSize
this.magnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
this.sideMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
this.channelMaxMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
}
/** Called by the bridge whenever the host emits a new frame. */
setMagnitudes(magnitudes: Float32Array, side?: Float32Array, channelMax?: Float32Array): void {
setMagnitudes(magnitudes: Float32Array, side?: Float32Array): void {
if (magnitudes.length !== this.magnitudes.length) {
this.magnitudes = new Float32Array(magnitudes.length)
}
@@ -42,12 +40,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
}
this.sideMagnitudes.set(side)
}
const resolvedChannelMax = channelMax && channelMax.length > 0 ? channelMax : magnitudes
if (resolvedChannelMax.length !== this.channelMaxMagnitudes.length) {
this.channelMaxMagnitudes = new Float32Array(resolvedChannelMax.length)
}
this.channelMaxMagnitudes.set(resolvedChannelMax)
}
isAvailable(): boolean {
@@ -59,7 +51,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
this.fftSize = size
this.magnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
this.sideMagnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
this.channelMaxMagnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
}
}
@@ -100,14 +91,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
return count
}
fillChannelMaxMagnitudes(output: Float32Array): number {
const count = Math.min(output.length, this.channelMaxMagnitudes.length)
if (count > 0) {
output.set(this.channelMaxMagnitudes.subarray(0, count), 0)
}
return count
}
getMagnitudes(): Float32Array {
return this.magnitudes
}
@@ -120,10 +103,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
return this.sideMagnitudes
}
getChannelMaxMagnitudes(): Float32Array {
return this.channelMaxMagnitudes
}
process(_audioData: Float32Array): Float32Array {
return this.magnitudes
}
@@ -135,6 +114,5 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
reset(): void {
this.magnitudes.fill(FFT_SILENCE_DB)
this.sideMagnitudes.fill(FFT_SILENCE_DB)
this.channelMaxMagnitudes.fill(FFT_SILENCE_DB)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import type { VectorscopeNativeAnalyzer, VectorscopeMultibandPointsResult } from
/**
* Drop-in `VectorscopeNativeAnalyzer` for the plugin webview.
*
* The vectorscope DSP (full-band channels + 3-band split, circular buffers) runs in
* The vectorscope DSP (channel lowpass + 3-band split, circular buffers) runs in
* the C++ plugin, which pushes the most recent display points each frame — either
* a standard X/Y cloud or a multiband (6 floats/point: lowL,lowR,midL,midR,highL,
* highR) cloud, depending on the active mode. This shim caches whichever arrived
+10 -39
View File
@@ -1,16 +1,10 @@
import { useEffect, useLayoutEffect, useRef, type JSX } from 'react'
import { useEffect, useRef, type JSX } from 'react'
import { Oscilloscope } from '../renderer/visualizers/Oscilloscope'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedOscilloscopeTheme } from '../types/theme'
import type { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions'
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
import {
ScopeMeasurementOverlay,
useScopeMeasurement,
} from '../renderer/components/ScopeMeasurementOverlay'
interface OscilloscopeScopeProps {
dataSource: PluginWebViewDataSource
@@ -28,16 +22,6 @@ export default function OscilloscopeScope({
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Oscilloscope | null>(null)
const rotationRef = useRef(settings.rotation)
const applySizeRef = useRef<(() => void) | null>(null)
rotationRef.current = settings.rotation
const measurementController = useScopeMeasurement({
containerRef,
enabled: true,
rotation: settings.rotation,
mirrorHorizontal: settings.mirrorHorizontal,
getSource: () => vizRef.current,
})
useEffect(() => {
const container = containerRef.current
@@ -52,12 +36,16 @@ export default function OscilloscopeScope({
vizRef.current = viz
const applySize = (): void => {
const { changed } = applyPluginScopeCanvasLayout(container, canvas, rotationRef.current)
if (changed) {
const rect = container.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
viz.resize()
}
}
applySizeRef.current = applySize
applySize()
viz.start()
@@ -66,7 +54,6 @@ export default function OscilloscopeScope({
return () => {
observer.disconnect()
applySizeRef.current = null
viz.dispose()
vizRef.current = null
}
@@ -78,25 +65,9 @@ export default function OscilloscopeScope({
vizRef.current?.setOptions(oscilloscopeSettingsToOptions(settings, theme))
}, [settings, theme])
useLayoutEffect(() => {
applySizeRef.current?.()
}, [settings.rotation])
return (
<div
ref={containerRef}
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
{...measurementController.pointerBindings}
>
<canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+9 -44
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, type JSX } from 'react'
import { useEffect, useRef, type JSX } from 'react'
import { Spectrogram } from '../renderer/visualizers/Spectrogram'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrogramTheme } from '../types/theme'
@@ -6,12 +6,6 @@ import type { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { spectrogramSettingsToOptions } from './spectrogramOptions'
import { resolveScrollingCanvasSize } from './scrollingCanvas'
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
import {
ScopeMeasurementOverlay,
useScopeMeasurement,
} from '../renderer/components/ScopeMeasurementOverlay'
interface SpectrogramScopeProps {
dataSource: PluginWebViewDataSource
@@ -29,16 +23,6 @@ export default function SpectrogramScope({
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Spectrogram | null>(null)
const rotationRef = useRef(settings.rotation)
const applySizeRef = useRef<(() => void) | null>(null)
rotationRef.current = settings.rotation
const measurementController = useScopeMeasurement({
containerRef,
enabled: true,
rotation: settings.rotation,
mirrorHorizontal: settings.mirrorHorizontal,
getSource: () => vizRef.current,
})
useEffect(() => {
const container = containerRef.current
@@ -53,17 +37,15 @@ export default function SpectrogramScope({
vizRef.current = viz
const applySize = (): void => {
const { changed } = applyPluginScopeCanvasLayout(
container,
canvas,
rotationRef.current,
resolveScrollingCanvasSize,
)
if (changed) {
const rect = container.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
viz.resize()
}
}
applySizeRef.current = applySize
applySize()
viz.start()
@@ -72,7 +54,6 @@ export default function SpectrogramScope({
return () => {
observer.disconnect()
applySizeRef.current = null
viz.dispose()
vizRef.current = null
}
@@ -84,25 +65,9 @@ export default function SpectrogramScope({
vizRef.current?.setOptions(spectrogramSettingsToOptions(settings, theme))
}, [settings, theme])
useLayoutEffect(() => {
applySizeRef.current?.()
}, [settings.rotation])
return (
<div
ref={containerRef}
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
{...measurementController.pointerBindings}
>
<canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+12 -53
View File
@@ -6,16 +6,10 @@ import type { SpectrumPeakInfo } from '../types/spectrum'
import type { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { spectrumSettingsToOptions } from './spectrumOptions'
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
import {
ScopeMeasurementOverlay,
useScopeMeasurement,
} from '../renderer/components/ScopeMeasurementOverlay'
import { emitToHost } from './juceBridge'
import {
formatSpectrumPeakDbfs,
formatSpectrumPeakDb,
formatSpectrumPeakFrequency,
measureCanvasResizeState,
resolveFollowingPeakOverlayStyle,
type CanvasResizeState,
type SizeMeasurement,
@@ -38,24 +32,11 @@ export default function SpectrumScope({
const canvasRef = useRef<HTMLCanvasElement>(null)
const analyzerRef = useRef<SpectrumAnalyzer | null>(null)
const resizeStateRef = useRef<CanvasResizeState | null>(null)
const rotationRef = useRef(settings.rotation)
const applySizeRef = useRef<(() => void) | null>(null)
const peakOverlayRef = useRef<HTMLDivElement | null>(null)
const [peak, setPeak] = useState<SpectrumPeakInfo | null>(null)
const [overlaySize, setOverlaySize] = useState<SizeMeasurement | null>(null)
const peakMode = settings.peakInfoMode
rotationRef.current = settings.rotation
const measurementController = useScopeMeasurement({
containerRef,
enabled: true,
rotation: settings.rotation,
mirrorHorizontal: settings.mirrorHorizontal,
getSource: () => analyzerRef.current,
onActiveChange: (active) => {
emitToHost('prismScopeMeasurement', { active })
},
})
// Create the analyzer once per data source / shim.
useEffect(() => {
@@ -73,13 +54,14 @@ export default function SpectrumScope({
analyzerRef.current = analyzer
const applySize = (): void => {
const { changed, layout } = applyPluginScopeCanvasLayout(container, canvas, rotationRef.current)
resizeStateRef.current = layout
if (changed) {
const state = measureCanvasResizeState(container)
resizeStateRef.current = state
if (canvas.width !== state.pixelWidth || canvas.height !== state.pixelHeight) {
canvas.width = state.pixelWidth
canvas.height = state.pixelHeight
analyzer.resize()
}
}
applySizeRef.current = applySize
applySize()
analyzer.start()
@@ -88,7 +70,6 @@ export default function SpectrumScope({
return () => {
observer.disconnect()
applySizeRef.current = null
analyzer.dispose()
analyzerRef.current = null
}
@@ -106,10 +87,6 @@ export default function SpectrumScope({
})
}, [settings, theme])
useLayoutEffect(() => {
applySizeRef.current?.()
}, [settings.rotation])
// Measure the overlay so "following" placement can avoid the screen edges.
useLayoutEffect(() => {
const overlay = peakOverlayRef.current
@@ -130,37 +107,19 @@ export default function SpectrumScope({
const showPeak = peakMode !== 'off' && peak !== null
const overlayStyle: CSSProperties | undefined =
peakMode === 'following' && peak
? resolveFollowingPeakOverlayStyle(
peak,
resizeStateRef.current,
overlaySize,
settings.rotation,
settings.mirrorHorizontal,
)
? resolveFollowingPeakOverlayStyle(peak, resizeStateRef.current, overlaySize)
: undefined
return (
<div
ref={containerRef}
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
{...measurementController.pointerBindings}
>
<canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
{!measurementController.active && showPeak && peak && (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
{showPeak && peak && (
<div
ref={peakMode === 'following' ? peakOverlayRef : null}
className={['scope-module__peak-info', peakMode === 'following' ? 'is-following' : 'is-corner'].join(' ')}
style={overlayStyle}
>
<span className="scope-module__peak-info-value">{formatSpectrumPeakDbfs(peak.dbfs)}</span>
<span className="scope-module__peak-info-value">{formatSpectrumPeakDb(peak.db)}</span>
<span className="scope-module__peak-info-separator">/</span>
<span className="scope-module__peak-info-value">{formatSpectrumPeakFrequency(peak.frequencyHz)}</span>
<span className="scope-module__peak-info-separator">/</span>
+9 -44
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, type JSX } from 'react'
import { useEffect, useRef, type JSX } from 'react'
import { Waveform } from '../renderer/visualizers/Waveform'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedWaveformTheme } from '../types/theme'
@@ -6,12 +6,6 @@ import type { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { waveformSettingsToOptions } from './waveformOptions'
import { resolveScrollingCanvasSize } from './scrollingCanvas'
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
import {
ScopeMeasurementOverlay,
useScopeMeasurement,
} from '../renderer/components/ScopeMeasurementOverlay'
interface WaveformScopeProps {
dataSource: PluginWebViewDataSource
@@ -29,16 +23,6 @@ export default function WaveformScope({
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Waveform | null>(null)
const rotationRef = useRef(settings.rotation)
const applySizeRef = useRef<(() => void) | null>(null)
rotationRef.current = settings.rotation
const measurementController = useScopeMeasurement({
containerRef,
enabled: true,
rotation: settings.rotation,
mirrorHorizontal: settings.mirrorHorizontal,
getSource: () => vizRef.current,
})
useEffect(() => {
const container = containerRef.current
@@ -53,17 +37,15 @@ export default function WaveformScope({
vizRef.current = viz
const applySize = (): void => {
const { changed } = applyPluginScopeCanvasLayout(
container,
canvas,
rotationRef.current,
resolveScrollingCanvasSize,
)
if (changed) {
const rect = container.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
viz.resize()
}
}
applySizeRef.current = applySize
applySize()
viz.start()
@@ -72,7 +54,6 @@ export default function WaveformScope({
return () => {
observer.disconnect()
applySizeRef.current = null
viz.dispose()
vizRef.current = null
}
@@ -84,25 +65,9 @@ export default function WaveformScope({
vizRef.current?.setOptions(waveformSettingsToOptions(settings, theme))
}, [settings, theme])
useLayoutEffect(() => {
applySizeRef.current?.()
}, [settings.rotation])
return (
<div
ref={containerRef}
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
{...measurementController.pointerBindings}
>
<canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+5 -15
View File
@@ -16,15 +16,12 @@ export interface SpectrumFrame {
magnitudes: Float32Array
/** Side magnitudes in dB (same length); empty if unavailable. */
side: Float32Array
/** Smoothed max(L, R) magnitudes in dBFS (same length). */
channelMax: Float32Array
}
interface SpectrumFramePayload {
sampleRate?: number
magnitudes?: string
side?: string
channelMax?: string
}
type JuceBackend = {
@@ -103,19 +100,14 @@ export function base64ToFloat32Array(b64: string): Float32Array {
return new Float32Array(bytes.buffer, 0, byteLength >> 2)
}
export function decodeSpectrumFrame(payload: unknown): SpectrumFrame | null {
function decodeFrame(payload: unknown): SpectrumFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const { sampleRate, magnitudes, side, channelMax } = payload as SpectrumFramePayload
const { sampleRate, magnitudes, side } = payload as SpectrumFramePayload
if (typeof magnitudes !== 'string' || magnitudes.length === 0) return null
const decodedMagnitudes = base64ToFloat32Array(magnitudes)
const decodedChannelMax = typeof channelMax === 'string'
? base64ToFloat32Array(channelMax)
: new Float32Array(0)
return {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
magnitudes: decodedMagnitudes,
magnitudes: base64ToFloat32Array(magnitudes),
side: typeof side === 'string' ? base64ToFloat32Array(side) : new Float32Array(0),
channelMax: decodedChannelMax.length > 0 ? decodedChannelMax : decodedMagnitudes,
}
}
@@ -136,7 +128,6 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v
const sampleRate = 48000
const mid = new Float32Array(binCount)
const side = new Float32Array(binCount).fill(-100)
const channelMax = new Float32Array(binCount)
let phase = 0
const tick = (): void => {
if (disposed) return
@@ -147,9 +138,8 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v
const peak2 = Math.exp(-Math.pow((t - 0.5) * 18, 2)) * 50
mid[i] = -100 + peak1 + peak2 + Math.random() * 6
side[i] = -100 + peak2 * 0.4 + Math.random() * 4
channelMax[i] = mid[i]
}
handlers.onFrame({ sampleRate, magnitudes: mid, side, channelMax })
handlers.onFrame({ sampleRate, magnitudes: mid, side })
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
@@ -159,7 +149,7 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('spectrumFrame', (payload) => {
const frame = decodeSpectrumFrame(payload)
const frame = decodeFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
+1 -4
View File
@@ -1,8 +1,5 @@
import { StrictMode, type JSX } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource/jetbrains-mono/latin-400.css'
import '@fontsource/jetbrains-mono/latin-600.css'
import '@fontsource/jetbrains-mono/latin-700.css'
import '../renderer/styles/globals.css'
import './styles.css'
import ScopeApp from './ScopeApp'
@@ -188,7 +185,7 @@ function buildApp(): JSX.Element {
const analyzer = new BridgeSpectrumAnalyzer(2048)
connectSpectrumBridge({
onFrame: (frame) => {
analyzer.setMagnitudes(frame.magnitudes, frame.side, frame.channelMax)
analyzer.setMagnitudes(frame.magnitudes, frame.side)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
+26 -25
View File
@@ -1,11 +1,5 @@
import type { CSSProperties } from 'react'
import type { SpectrumPeakInfo } from '../types/spectrum'
import type { ScopeDisplayRotation } from '../types/scopeTransform'
import {
measureScopeCanvasLayout,
transformNormalizedScopePoint,
type ScopeCanvasLayout,
} from '../renderer/scopeCanvasTransform'
/**
* Peak-overlay positioning + formatting, mirroring ScopeModule.tsx so the plugin's
@@ -13,7 +7,13 @@ import {
* copy (pure functions) so the plugin doesn't import the heavy ScopeModule.
*/
export type CanvasResizeState = ScopeCanvasLayout
export interface CanvasResizeState {
cssWidth: number
cssHeight: number
pixelWidth: number
pixelHeight: number
dpr: number
}
export interface SizeMeasurement {
width: number
@@ -28,11 +28,11 @@ function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function formatSpectrumPeakDbfs(value: number): string {
export function formatSpectrumPeakDb(value: number): string {
if (!Number.isFinite(value)) {
return '--'
}
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dBFS`
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dB`
}
export function formatSpectrumPeakFrequency(value: number): string {
@@ -45,19 +45,25 @@ export function formatSpectrumPeakFrequency(value: number): string {
return `${value.toFixed(2)}Hz`
}
export function measureCanvasResizeState(
container: HTMLElement,
rotation: ScopeDisplayRotation = 0,
): CanvasResizeState {
return measureScopeCanvasLayout(container, rotation)
export function measureCanvasResizeState(container: HTMLElement): CanvasResizeState {
const rect = container.getBoundingClientRect()
const cssWidth = Math.max(1, Math.floor(rect.width))
const cssHeight = Math.max(1, Math.floor(rect.height))
const dpr = window.devicePixelRatio || 1
return {
cssWidth,
cssHeight,
pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)),
pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)),
dpr,
}
}
export function resolveFollowingPeakOverlayStyle(
peakInfo: SpectrumPeakInfo,
resizeState: CanvasResizeState | null,
overlaySize: SizeMeasurement | null,
rotation: ScopeDisplayRotation = 0,
mirrorHorizontal = false,
): CSSProperties {
if (!resizeState) {
return {
@@ -66,17 +72,12 @@ export function resolveFollowingPeakOverlayStyle(
}
}
const width = resizeState.viewportCssWidth
const height = resizeState.viewportCssHeight
const width = resizeState.cssWidth
const height = resizeState.cssHeight
const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX
const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX
const transformedPeak = transformNormalizedScopePoint(
{ x: peakInfo.normalizedX, y: peakInfo.normalizedY },
rotation,
mirrorHorizontal,
)
const peakX = transformedPeak.x * width
const peakY = transformedPeak.y * height
const peakX = peakInfo.normalizedX * width
const peakY = peakInfo.normalizedY * height
const maxLeft = Math.max(
SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
-34
View File
@@ -1,34 +0,0 @@
import type { ScopeDisplayRotation } from '../types/scopeTransform'
import {
measureScopeCanvasLayout,
type ScopeCanvasLayout,
} from '../renderer/scopeCanvasTransform'
type PixelSizeResolver = (
cssWidth: number,
cssHeight: number,
dpr: number,
) => { width: number; height: number }
export function applyPluginScopeCanvasLayout(
container: HTMLElement,
canvas: HTMLCanvasElement,
rotation: ScopeDisplayRotation,
pixelSizeResolver?: PixelSizeResolver,
): { changed: boolean; layout: ScopeCanvasLayout } {
const layout = measureScopeCanvasLayout(container, rotation)
const resolvedPixels = pixelSizeResolver
? pixelSizeResolver(layout.cssWidth, layout.cssHeight, layout.dpr)
: { width: layout.pixelWidth, height: layout.pixelHeight }
canvas.style.width = `${layout.cssWidth}px`
canvas.style.height = `${layout.cssHeight}px`
const changed = canvas.width !== resolvedPixels.width || canvas.height !== resolvedPixels.height
if (changed) {
canvas.width = resolvedPixels.width
canvas.height = resolvedPixels.height
}
return { changed, layout }
}
+1 -8
View File
@@ -1,7 +1,6 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrogramTheme } from '../types/theme'
import type { SpectrogramOptions } from '../renderer/visualizers/Spectrogram'
import { nominalFrequencyBoundsForRange } from '../types/frequencyScale'
/**
* Map Prism's spectrogram settings + resolved theme to Spectrogram options.
@@ -11,23 +10,17 @@ export function spectrogramSettingsToOptions(
settings: ScopeSettings['spectrogram'],
theme: ResolvedSpectrogramTheme,
): SpectrogramOptions {
const range = nominalFrequencyBoundsForRange(settings.frequencyRangeMode)
return {
lineColor: theme.mono,
heatColors: theme.heatColors,
backgroundColor: theme.background,
gridColor: theme.guides,
labelColor: theme.labels,
minFrequency: range.minFrequency,
maxFrequency: range.maxFrequency,
fftSize: settings.fftSize,
tiltDbPerOctave: settings.tiltDbPerOctave,
scrollSpeed: settings.scrollSpeed,
contrast: settings.contrast,
clarityMode: settings.clarityMode,
scaleMode: settings.scaleMode,
showGrid: settings.showGrid,
orientation: 'horizontal',
orientation: settings.orientation,
colorScheme: settings.colorScheme,
}
}
-5
View File
@@ -1,7 +1,6 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrumTheme } from '../types/theme'
import type { SpectrumAnalyzerOptions } from '../renderer/visualizers/SpectrumAnalyzer'
import { nominalFrequencyBoundsForRange } from '../types/frequencyScale'
/**
* Map Prism's spectrum settings + resolved theme to SpectrumAnalyzer options.
@@ -12,7 +11,6 @@ export function spectrumSettingsToOptions(
settings: ScopeSettings['spectrum'],
theme: ResolvedSpectrumTheme,
): SpectrumAnalyzerOptions {
const range = nominalFrequencyBoundsForRange(settings.frequencyRangeMode)
return {
lineColor: theme.line,
secondaryLineColor: theme.sideLine,
@@ -21,9 +19,6 @@ export function spectrumSettingsToOptions(
heatBaseColor: theme.heatBase,
backgroundColor: theme.background,
gridColor: theme.guides,
scaleType: settings.scaleMode,
minFrequency: range.minFrequency,
maxFrequency: range.maxFrequency,
fftSize: settings.fftSize,
tiltDbPerOctave: settings.tiltDbPerOctave,
heatmapFill: settings.heatmap,
+10 -2
View File
@@ -3,13 +3,21 @@ import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
import type { ScopeKind } from '../types/scope'
import type { PrismResolvedTheme } from '../types/theme'
import { createBundledThemes, createDefaultTheme, parseThemeFileContent, resolveTheme } from '../shared/themeState'
import { mergeScopeSettings as normalizeAllScopeSettings } from '../shared/profileState'
import { emitToHost, onHostEvent } from './juceBridge'
const DEFAULT_THEME = resolveTheme(createDefaultTheme())
function mergeScopeSettings<K extends ScopeKind>(kind: K, raw: unknown): ScopeSettings[K] {
return normalizeAllScopeSettings({ [kind]: raw })[kind]
const defaults = DEFAULT_SCOPE_SETTINGS[kind] as Record<string, unknown>
if (typeof raw !== 'object' || raw === null) return { ...defaults } as ScopeSettings[K]
const parsed = raw as Record<string, unknown>
const next: Record<string, unknown> = { ...defaults }
for (const key of Object.keys(defaults)) {
if (key in parsed && typeof parsed[key] === typeof defaults[key]) {
next[key] = parsed[key]
}
}
return next as ScopeSettings[K]
}
function resolveAppTheme(themeId: string, themeFile: string): PrismResolvedTheme {
-2
View File
@@ -16,14 +16,12 @@ export function vectorscopeSettingsToOptions(
gridMajorColor: theme.guides,
gridMinorColor: theme.guidesSecondary,
labelColor: theme.labels,
phaseRiskColor: theme.phaseRisk,
bandColors: {
low: theme.bandLow,
mid: theme.bandMid,
high: theme.bandHigh,
},
mode: settings.mode,
zoomDb: settings.zoomDb,
multiband: settings.multiband,
showGrid: settings.showGrid,
persistence: settings.persistence,
-57
View File
@@ -1,6 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { AppBuildInfo } from '../types/appBuildInfo'
import type { AudioClipDragPayload } from '../types/audioClip'
import type { CaptureBackendSupport } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type {
@@ -33,13 +32,6 @@ import type { DialogOptions, DialogResult } from '../types/dialog'
import type { UpdateCheckResult } from '../types/updates'
import type { WindowCapabilities } from '../types/windowCapabilities'
import type { ResizeDirection } from '../types/windowResize'
import type { WindowBackgroundSnapshot, WindowBackgroundState } from '../types/windowState'
import type {
DesktopIntegrationSnapshot,
LoginLaunchMode,
TrayRendererCommand,
TrayRendererState,
} from '../types/desktopIntegration'
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
import { resolveWindowCapabilities } from '../shared/windowCapabilities'
import { getCaptureBackendSupport } from './captureSupport'
@@ -49,7 +41,6 @@ const windowCapabilities: WindowCapabilities = resolveWindowCapabilities({
platform: process.platform,
argv: process.argv,
env: process.env,
osVersion: process.getSystemVersion(),
})
// Expose Electron API to renderer
@@ -59,38 +50,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
getAppBuildInfo: () => ipcRenderer.invoke('app:get-build-info') as Promise<AppBuildInfo>,
minimize: () => ipcRenderer.send('window:minimize'),
close: () => ipcRenderer.send('window:close'),
desktopIntegration: {
get: () => ipcRenderer.invoke('desktop-integration:get') as Promise<DesktopIntegrationSnapshot>,
setCloseToTray: (enabled: boolean) => ipcRenderer.invoke(
'desktop-integration:set-close-to-tray',
enabled,
) as Promise<DesktopIntegrationSnapshot>,
setOpenAtLogin: (enabled: boolean) => ipcRenderer.invoke(
'desktop-integration:set-open-at-login',
enabled,
) as Promise<DesktopIntegrationSnapshot>,
setLoginLaunchMode: (mode: LoginLaunchMode) => ipcRenderer.invoke(
'desktop-integration:set-login-launch-mode',
mode,
) as Promise<DesktopIntegrationSnapshot>,
onChanged: (callback: (snapshot: DesktopIntegrationSnapshot) => void) => {
const handler = (_event: Electron.IpcRendererEvent, snapshot: DesktopIntegrationSnapshot): void => {
callback(snapshot)
}
ipcRenderer.on('desktop-integration:changed', handler)
return () => ipcRenderer.removeListener('desktop-integration:changed', handler)
},
},
trayControls: {
markReady: () => ipcRenderer.send('tray-controls:renderer-ready'),
markNotReady: () => ipcRenderer.send('tray-controls:renderer-not-ready'),
publishState: (state: TrayRendererState) => ipcRenderer.send('tray-controls:publish-state', state),
onCommand: (callback: (command: TrayRendererCommand) => void) => {
const handler = (_event: Electron.IpcRendererEvent, command: TrayRendererCommand): void => callback(command)
ipcRenderer.on('tray-controls:command', handler)
return () => ipcRenderer.removeListener('tray-controls:command', handler)
},
},
startWindowMove: () => ipcRenderer.send('window:start-move'),
stopWindowMove: () => ipcRenderer.send('window:stop-move'),
startWindowResize: (edge: ResizeDirection) => ipcRenderer.send('window:start-resize', edge),
@@ -100,19 +59,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position),
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
getWindowBackground: () => ipcRenderer.invoke('window:get-background') as Promise<WindowBackgroundSnapshot>,
setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise<WindowBackgroundSnapshot>,
isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise<boolean>,
getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport,
audioClips: {
startDrag: (payload: AudioClipDragPayload) => ipcRenderer.send('audio-clips:start-drag', payload),
revealFolder: () => ipcRenderer.invoke('audio-clips:reveal-folder') as Promise<void>,
onDragError: (callback: (message: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, message: string): void => callback(message)
ipcRenderer.on('audio-clips:drag-error', handler)
return () => ipcRenderer.removeListener('audio-clips:drag-error', handler)
},
},
getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise<NowPlayingState>,
setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise<NowPlayingState>,
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => {
@@ -172,11 +120,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('window:always-on-top-changed', handler)
return () => ipcRenderer.removeListener('window:always-on-top-changed', handler)
},
onWindowBackgroundChanged: (callback: (snapshot: WindowBackgroundSnapshot) => void) => {
const handler = (_event: Electron.IpcRendererEvent, snapshot: WindowBackgroundSnapshot): void => callback(snapshot)
ipcRenderer.on('window:background-changed', handler)
return () => ipcRenderer.removeListener('window:background-changed', handler)
},
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => {
const handler = (_event: Electron.IpcRendererEvent, bounds: WindowBounds): void => callback(bounds)
ipcRenderer.on('window:bounds-changed', handler)
+2 -24
View File
@@ -5,25 +5,19 @@ import SettingsPanel from './components/SettingsPanel'
import BottomBar from './components/BottomBar'
import ScopePopoutBridge from './components/ScopePopoutBridge'
import AppBanner from './components/AppBanner'
import WindowResizeOverlay from './components/WindowResizeOverlay'
import TrayControlBridge from './components/TrayControlBridge'
import { resolveMainWindowSettingsHeight } from './mainWindowSettings'
import { useSettingsStore } from './stores/settingsStore'
import { startAudioDeviceWatcher, useAudioStore } from './stores/audioStore'
import { useNowPlayingStore } from './stores/nowPlayingStore'
import { useThemeStore } from './stores/themeStore'
import { useUiStore } from './stores/uiStore'
import { useWindowBackgroundStore } from './stores/windowBackgroundStore'
import { useUpdateStore } from './stores/updateStore'
import { useDesktopIntegrationStore } from './stores/desktopIntegrationStore'
import { getRendererWindowCapabilities } from './windowCapabilities'
export default function App(): JSX.Element {
const [toolbarVisible, setToolbarVisible] = useState(false)
const [measurementActive, setMeasurementActive] = useState(false)
const [settingsPanelHeight, setSettingsPanelHeight] = useState(0)
const [bottomBarHeight, setBottomBarHeight] = useState(0)
const [trayReady, setTrayReady] = useState(false)
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const settingsOpenRef = useRef(false)
const externalProfileOpenQueueRef = useRef(Promise.resolve())
@@ -44,11 +38,7 @@ export default function App(): JSX.Element {
const toggleSettings = useUiStore((s) => s.toggleSettings)
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
const showBanner = useUiStore((s) => s.showBanner)
const initializeWindowBackground = useWindowBackgroundStore((s) => s.initialize)
const windowBackgroundMode = useWindowBackgroundStore((s) => s.effective.mode)
const useNativeDragRegions = getRendererWindowCapabilities().useNativeDragRegions
const initializeDesktopIntegration = useDesktopIntegrationStore((s) => s.initialize)
const applyDesktopIntegrationSnapshot = useDesktopIntegrationStore((s) => s.applySnapshot)
const isNowPlayingVisible = !hiddenScopes.has('nowPlaying')
&& (scopeOrder.includes('nowPlaying') || scopePopouts.nowPlaying?.poppedOut === true)
@@ -69,10 +59,6 @@ export default function App(): JSX.Element {
void useUpdateStore.getState().checkForUpdates()
}, [])
useEffect(() => {
void initializeWindowBackground()
}, [initializeWindowBackground])
useEffect(() => {
let isDisposed = false
@@ -81,7 +67,6 @@ export default function App(): JSX.Element {
await initializeProfiles()
await initializeNowPlaying()
if (!isDisposed) {
setTrayReady(true)
window.electronAPI.notifyRendererReady()
}
})()
@@ -162,11 +147,6 @@ export default function App(): JSX.Element {
updateMainWindowBounds,
])
useEffect(() => {
void initializeDesktopIntegration()
return window.electronAPI.desktopIntegration.onChanged(applyDesktopIntegrationSnapshot)
}, [applyDesktopIntegrationSnapshot, initializeDesktopIntegration])
useEffect(() => {
void initializeNowPlaying()
.then(() => setNowPlayingConsumerActive(isNowPlayingVisible))
@@ -303,17 +283,16 @@ export default function App(): JSX.Element {
onMouseUp={useNativeDragRegions ? undefined : handleAltDragEnd}
>
<div
className={`prism-toolbar-layer ${toolbarVisible && !measurementActive ? 'is-visible' : ''}`.trim()}
className={`prism-toolbar-layer ${toolbarVisible ? 'is-visible' : ''}`.trim()}
>
<Toolbar onOpenSettings={handleToggleSettings} settingsOpen={settingsOpen} />
</div>
<div className="prism-strip-region">
<Strip onMeasurementActiveChange={setMeasurementActive} />
<Strip />
</div>
<ScopePopoutBridge />
<TrayControlBridge ready={trayReady} />
<AppBanner />
@@ -326,7 +305,6 @@ export default function App(): JSX.Element {
<BottomBar onClose={handleCloseSettings} onHeightChange={setBottomBarHeight} />
</div>
{windowBackgroundMode !== 'solid' && <WindowResizeOverlay />}
</div>
)
}
+2 -3
View File
@@ -1,7 +1,7 @@
import type { ScopeKind } from '../types/scope'
import { MIN_LOUDNESS_METER_WIDTH_PX } from '../shared/scopeSizing'
const DEFAULT_COLLAPSED_SCOPE_WEIGHT = 1
export const LOCKED_LOUDNESS_METER_WIDTH_PX = 150
function usesCollapsedDefaultWeight(scope: ScopeKind): boolean {
return scope === 'spectrogram'
@@ -27,8 +27,7 @@ export function buildAnalyzerGridTemplateColumns(
}
if (scope === 'lufsmeter') {
const resolvedWeight = weight > 0 ? weight : DEFAULT_COLLAPSED_SCOPE_WEIGHT
return `minmax(${MIN_LOUDNESS_METER_WIDTH_PX}px, ${resolvedWeight}fr)`
return `minmax(${LOCKED_LOUDNESS_METER_WIDTH_PX}px, ${LOCKED_LOUDNESS_METER_WIDTH_PX}px)`
}
if (usesCollapsedDefaultWeight(scope) && weight <= 0) {
-103
View File
@@ -6,13 +6,7 @@
import { audioRouter } from './AudioRouter'
import { nativeVisualizerTransport } from './NativeVisualizerTransport'
import { RollingAudioBuffer } from './RollingAudioBuffer'
import { applyInputGainToStereoSamples, inputGainDbToLinear } from './inputGain'
import type {
RollingAudioSnapshot,
RollingCaptureDurationSeconds,
RollingCaptureStatus,
} from '../../types/audioClip'
import type {
CaptureBackendKind,
CaptureBackendSupport,
@@ -155,7 +149,6 @@ export interface CaptureManagerStatus {
}
type StatusListener = (status: CaptureManagerStatus) => void
type RollingCaptureStatusListener = (status: RollingCaptureStatus) => void
const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__'
@@ -648,9 +641,6 @@ class AudioCapture {
private inputGainDb = 0
private inputGainLinear = 1
private statusListeners = new Set<StatusListener>()
private rollingCaptureSeconds: RollingCaptureDurationSeconds | null = null
private rollingAudioBuffer: RollingAudioBuffer | null = null
private rollingCaptureStatusListeners = new Set<RollingCaptureStatusListener>()
constructor() {
this.deviceInputBackend = new DeviceInputCaptureBackend(this.deviceInputRuntime)
@@ -673,14 +663,6 @@ class AudioCapture {
}
}
subscribeRollingCaptureStatus(listener: RollingCaptureStatusListener): () => void {
this.rollingCaptureStatusListeners.add(listener)
listener(this.getRollingCaptureStatus())
return () => {
this.rollingCaptureStatusListeners.delete(listener)
}
}
async refreshBackendSupport(): Promise<CaptureBackendSupport> {
this.backendSupport = null
this.backendSupportPromise = null
@@ -729,10 +711,6 @@ class AudioCapture {
backendStatus.channelCount,
backendStatus.kind,
)
this.beginRollingCaptureSession(
backendStatus.sampleRate,
backendStatus.channelCount,
)
nativeVisualizerTransport.reset(audioRouter.getSessionState())
this.emitStatus()
}
@@ -808,43 +786,6 @@ class AudioCapture {
}
}
getRollingCaptureStatus(): RollingCaptureStatus {
const buffer = this.rollingAudioBuffer
return {
durationSeconds: this.rollingCaptureSeconds,
hasAudio: Boolean(buffer && buffer.frameCount > 0),
ready: Boolean(buffer?.isReady),
allocatedBytes: buffer?.allocatedBytes ?? 0,
}
}
setRollingCaptureSeconds(durationSeconds: RollingCaptureDurationSeconds | null): void {
if (durationSeconds === this.rollingCaptureSeconds) return
this.rollingCaptureSeconds = durationSeconds
if (durationSeconds === null) {
this.rollingAudioBuffer = null
this.emitRollingCaptureStatus()
return
}
if (this.rollingAudioBuffer) {
this.rollingAudioBuffer.resize(durationSeconds)
} else if (this.sessionId !== null && this.activeBackend) {
const status = this.activeBackend.getStatus()
this.rollingAudioBuffer = new RollingAudioBuffer(
durationSeconds,
status.sampleRate,
status.channelCount,
)
}
this.emitRollingCaptureStatus()
}
takeRollingCaptureSnapshot(): RollingAudioSnapshot | null {
return this.rollingAudioBuffer?.snapshot() ?? null
}
private async ensureBackendSupport(): Promise<CaptureBackendSupport> {
if (this.backendSupport) {
return this.backendSupport
@@ -895,11 +836,6 @@ class AudioCapture {
}
private async stopActiveCapture(): Promise<void> {
if (this.rollingAudioBuffer) {
this.rollingAudioBuffer = null
this.emitRollingCaptureStatus()
}
if (this.sessionId !== null) {
audioRouter.endSession()
nativeVisualizerTransport.reset(audioRouter.getSessionState())
@@ -928,31 +864,6 @@ class AudioCapture {
capturedAt: chunk.capturedAt,
sequence: chunk.sequence,
})
let rollingAudioBuffer = this.rollingAudioBuffer
if (rollingAudioBuffer) {
const normalizedChannelCount = chunk.channelCount > 1 ? 2 : 1
if (rollingAudioBuffer.channelCount !== normalizedChannelCount) {
const sampleRate = this.activeBackend.getStatus().sampleRate
rollingAudioBuffer = new RollingAudioBuffer(
this.rollingCaptureSeconds!,
sampleRate,
normalizedChannelCount,
)
this.rollingAudioBuffer = rollingAudioBuffer
this.emitRollingCaptureStatus()
}
const hadAudio = rollingAudioBuffer.frameCount > 0
const wasReady = rollingAudioBuffer.isReady
rollingAudioBuffer.append(chunk.left, chunk.right, chunk.channelCount)
if (
hadAudio !== (rollingAudioBuffer.frameCount > 0)
|| wasReady !== rollingAudioBuffer.isReady
) {
this.emitRollingCaptureStatus()
}
}
}
setInputGain(db: number): void {
@@ -967,20 +878,6 @@ class AudioCapture {
listener(status)
}
}
private beginRollingCaptureSession(sampleRate: number, channelCount: number): void {
this.rollingAudioBuffer = this.rollingCaptureSeconds === null
? null
: new RollingAudioBuffer(this.rollingCaptureSeconds, sampleRate, channelCount)
this.emitRollingCaptureStatus()
}
private emitRollingCaptureStatus(): void {
const status = this.getRollingCaptureStatus()
for (const listener of this.rollingCaptureStatusListeners) {
listener(status)
}
}
}
export const audioCapture = new AudioCapture()
+16 -20
View File
@@ -93,7 +93,7 @@ type ScopeRingMap = {
spectrum: FixedChunkRing<StereoChunkRecord>
oscilloscope: FixedChunkRing<MonoChunkRecord>
vectorscope: FixedChunkRing<StereoChunkRecord>
spectrogram: FixedChunkRing<StereoChunkRecord>
spectrogram: FixedChunkRing<MonoChunkRecord>
vumeter: FixedChunkRing<StereoChunkRecord>
lufsmeter: FixedChunkRing<StereoChunkRecord>
waveform: FixedChunkRing<StereoChunkRecord>
@@ -215,7 +215,7 @@ export class AudioRouter {
spectrum: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.spectrum),
oscilloscope: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.oscilloscope),
vectorscope: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vectorscope),
spectrogram: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.spectrogram),
spectrogram: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.spectrogram),
vumeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vumeter),
lufsmeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.lufsmeter),
waveform: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.waveform),
@@ -363,10 +363,11 @@ export class AudioRouter {
const activeDemand = this.getActiveDemand()
const needsSpectrum = Boolean(activeDemand.spectrum)
const needsStereo = Boolean(activeDemand.spectrogram || activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform)
const needsMono = Boolean(activeDemand.spectrogram)
const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform)
const needsLeft = Boolean(activeDemand.oscilloscope)
if (!needsSpectrum && !needsStereo && !needsLeft) {
if (!needsSpectrum && !needsMono && !needsStereo && !needsLeft) {
this.undemandedChunks += 1
return
}
@@ -376,6 +377,14 @@ export class AudioRouter {
const leftSamples = left.length === len ? left : left.subarray(0, len)
const rightSamples = resolvedRight.length === len ? resolvedRight : resolvedRight.subarray(0, len)
let mono: Float32Array | null = null
if (needsMono) {
mono = new Float32Array(len)
for (let index = 0; index < len; index += 1) {
mono[index] = (leftSamples[index] + rightSamples[index]) * 0.5
}
}
if (activeDemand.oscilloscope) {
this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence })
}
@@ -384,8 +393,8 @@ export class AudioRouter {
this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence })
}
if (activeDemand.spectrogram) {
this.rings.spectrogram.push({ left: leftSamples, right: rightSamples, capturedAt, sequence })
if (activeDemand.spectrogram && mono) {
this.rings.spectrogram.push({ samples: mono, capturedAt, sequence })
}
if (activeDemand.vectorscope) {
@@ -433,20 +442,7 @@ export class AudioRouter {
flushPendingSpectrogramSamples(): Float32Array[] {
const records = this.rings.spectrogram.drain()
this.recordScopeDrain('spectrogram', records)
return records.map((record) => {
const length = Math.min(record.left.length, record.right.length)
const mono = new Float32Array(length)
for (let index = 0; index < length; index += 1) {
mono[index] = (record.left[index] + record.right[index]) * 0.5
}
return mono
})
}
flushPendingSpectrogramStereoSamples(): { left: Float32Array; right: Float32Array }[] {
const records = this.rings.spectrogram.drain()
this.recordScopeDrain('spectrogram', records)
return records.map((record) => ({ left: record.left, right: record.right }))
return records.map((record) => record.samples)
}
flushPendingVectorscopeSamples(): { left: Float32Array; right: Float32Array }[] {
-153
View File
@@ -1,153 +0,0 @@
import type {
RollingAudioSnapshot,
RollingCaptureDurationSeconds,
} from '../../types/audioClip'
function floatToPcm16(sample: number): number {
if (!Number.isFinite(sample)) return 0
const clamped = Math.max(-1, Math.min(1, sample))
return clamped < 0
? Math.round(clamped * 32768)
: Math.round(clamped * 32767)
}
function normalizeChannelCount(channelCount: number): 1 | 2 {
return channelCount > 1 ? 2 : 1
}
export class RollingAudioBuffer {
private samples: Int16Array
private capacityFrames: number
private writeFrameIndex = 0
private bufferedFrames = 0
readonly sampleRate: number
readonly channelCount: 1 | 2
private _durationSeconds: RollingCaptureDurationSeconds
constructor(
durationSeconds: RollingCaptureDurationSeconds,
sampleRate: number,
channelCount: number,
) {
this._durationSeconds = durationSeconds
this.sampleRate = Math.max(1, Math.floor(sampleRate) || 1)
this.channelCount = normalizeChannelCount(channelCount)
this.capacityFrames = Math.max(1, Math.floor(durationSeconds * this.sampleRate))
this.samples = new Int16Array(this.capacityFrames * this.channelCount)
}
get durationSeconds(): RollingCaptureDurationSeconds {
return this._durationSeconds
}
get frameCount(): number {
return this.bufferedFrames
}
get isReady(): boolean {
return this.bufferedFrames >= this.capacityFrames
}
get allocatedBytes(): number {
return this.samples.byteLength
}
append(left: Float32Array, right: Float32Array, channelCount: number): void {
const effectiveChannels = normalizeChannelCount(channelCount)
if (effectiveChannels !== this.channelCount) return
const availableFrames = this.channelCount === 1
? left.length
: Math.min(left.length, right.length)
if (availableFrames <= 0) return
const framesToWrite = Math.min(availableFrames, this.capacityFrames)
let sourceFrameIndex = availableFrames - framesToWrite
let remainingFrames = framesToWrite
while (remainingFrames > 0) {
const contiguousFrames = Math.min(
remainingFrames,
this.capacityFrames - this.writeFrameIndex,
)
for (let offset = 0; offset < contiguousFrames; offset += 1) {
const sourceIndex = sourceFrameIndex + offset
const destinationIndex = (this.writeFrameIndex + offset) * this.channelCount
this.samples[destinationIndex] = floatToPcm16(left[sourceIndex] ?? 0)
if (this.channelCount === 2) {
this.samples[destinationIndex + 1] = floatToPcm16(right[sourceIndex] ?? 0)
}
}
this.writeFrameIndex = (this.writeFrameIndex + contiguousFrames) % this.capacityFrames
this.bufferedFrames = Math.min(
this.capacityFrames,
this.bufferedFrames + contiguousFrames,
)
sourceFrameIndex += contiguousFrames
remainingFrames -= contiguousFrames
}
}
resize(durationSeconds: RollingCaptureDurationSeconds): void {
if (durationSeconds === this._durationSeconds) return
const nextCapacityFrames = Math.max(1, Math.floor(durationSeconds * this.sampleRate))
const framesToKeep = Math.min(this.bufferedFrames, nextCapacityFrames)
const nextSamples = new Int16Array(nextCapacityFrames * this.channelCount)
if (framesToKeep > 0) {
const startFrame = (
this.writeFrameIndex - framesToKeep + this.capacityFrames
) % this.capacityFrames
this.copyFramesTo(nextSamples, startFrame, framesToKeep)
}
this._durationSeconds = durationSeconds
this.capacityFrames = nextCapacityFrames
this.samples = nextSamples
this.bufferedFrames = framesToKeep
this.writeFrameIndex = framesToKeep % nextCapacityFrames
}
snapshot(): RollingAudioSnapshot | null {
if (this.bufferedFrames <= 0) return null
const pcmSamples = new Int16Array(this.bufferedFrames * this.channelCount)
const startFrame = (
this.writeFrameIndex - this.bufferedFrames + this.capacityFrames
) % this.capacityFrames
this.copyFramesTo(pcmSamples, startFrame, this.bufferedFrames)
return {
pcmSamples,
sampleRate: this.sampleRate,
channelCount: this.channelCount,
frameCount: this.bufferedFrames,
}
}
private copyFramesTo(
destination: Int16Array,
startFrame: number,
frameCount: number,
): void {
const firstFrameCount = Math.min(frameCount, this.capacityFrames - startFrame)
const firstSampleStart = startFrame * this.channelCount
const firstSampleCount = firstFrameCount * this.channelCount
destination.set(
this.samples.subarray(firstSampleStart, firstSampleStart + firstSampleCount),
0,
)
const remainingFrames = frameCount - firstFrameCount
if (remainingFrames <= 0) return
destination.set(
this.samples.subarray(0, remainingFrames * this.channelCount),
firstSampleCount,
)
}
}
-31
View File
@@ -49,11 +49,9 @@ export interface SpectrumNativeAnalyzer {
fillRawMagnitudes(output: Float32Array): number
fillMagnitudes(output: Float32Array): number
fillSideMagnitudes(output: Float32Array): number
fillChannelMaxMagnitudes(output: Float32Array): number
getRawMagnitudes(): Float32Array | null
getMagnitudes(): Float32Array | null
getSideMagnitudes(): Float32Array | null
getChannelMaxMagnitudes(): Float32Array | null
process(audioData: Float32Array): Float32Array | null
binToFrequency(bin: number): number
reset(): void
@@ -195,19 +193,6 @@ export const spectrum: SpectrumNativeAnalyzer = {
return count
},
fillChannelMaxMagnitudes: (output: Float32Array): number => {
if (!nativeModule) return 0
const getter = nativeModule.spectrum.getChannelMaxMagnitudes
const magnitudes = typeof getter === 'function'
? nativeModule.spectrum.getChannelMaxMagnitudes()
: nativeModule.spectrum.getMagnitudes()
const count = Math.min(output.length, magnitudes.length)
if (count > 0) {
output.set(magnitudes.subarray(0, count), 0)
}
return count
},
getMagnitudes: (): Float32Array | null => {
if (!nativeModule) return null
return nativeModule.spectrum.getMagnitudes()
@@ -223,14 +208,6 @@ export const spectrum: SpectrumNativeAnalyzer = {
return nativeModule.spectrum.getSideMagnitudes()
},
getChannelMaxMagnitudes: (): Float32Array | null => {
if (!nativeModule) return null
const getter = nativeModule.spectrum.getChannelMaxMagnitudes
return typeof getter === 'function'
? nativeModule.spectrum.getChannelMaxMagnitudes()
: nativeModule.spectrum.getMagnitudes()
},
process: (audioData: Float32Array): Float32Array | null => {
if (!nativeModule) return null
return nativeModule.spectrum.process(audioData)
@@ -248,7 +225,6 @@ export const spectrum: SpectrumNativeAnalyzer = {
export interface SpectrogramNativeAnalyzer {
configure(options: SpectrogramNativeOptions): void
process(audioData: Float32Array): SpectrogramNativeResult | null
processStereo?: (leftChannel: Float32Array, rightChannel: Float32Array) => SpectrogramNativeResult | null
reset(): void
isAvailable?: () => boolean
}
@@ -302,13 +278,6 @@ export const spectrogram: SpectrogramNativeAnalyzer = {
return nativeModule.spectrogram.process(audioData)
},
processStereo: (leftChannel: Float32Array, rightChannel: Float32Array): SpectrogramNativeResult | null => {
if (!nativeModule?.spectrogram) return null
return typeof nativeModule.spectrogram.processStereo === 'function'
? nativeModule.spectrogram.processStereo(leftChannel, rightChannel)
: nativeModule.spectrogram.process(leftChannel)
},
reset: (): void => {
nativeModule?.spectrogram?.reset()
},
-3
View File
@@ -108,11 +108,9 @@ export interface SpectrumModule {
fillRawMagnitudes(output: Float32Array): number;
fillMagnitudes(output: Float32Array): number;
fillSideMagnitudes(output: Float32Array): number;
fillChannelMaxMagnitudes(output: Float32Array): number;
getRawMagnitudes(): Float32Array;
getMagnitudes(): Float32Array;
getSideMagnitudes(): Float32Array;
getChannelMaxMagnitudes(): Float32Array;
process(audioData: Float32Array): Float32Array;
binToFrequency(bin: number): number;
reset(): void;
@@ -121,7 +119,6 @@ export interface SpectrumModule {
export interface SpectrogramModule {
configure(options: SpectrogramNativeOptions): void;
process(audioData: Float32Array): SpectrogramNativeResult;
processStereo(leftChannel: Float32Array, rightChannel: Float32Array): SpectrogramNativeResult;
reset(): void;
}
-233
View File
@@ -5,15 +5,10 @@ import { usePerformanceStore } from '../stores/performanceStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import { useUiStore } from '../stores/uiStore'
import { useWindowBackgroundStore } from '../stores/windowBackgroundStore'
import { useDesktopIntegrationStore } from '../stores/desktopIntegrationStore'
import { getRendererWindowCapabilities } from '../windowCapabilities'
import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll'
import type { ScopeKind } from '../../types/scope'
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
import { ROLLING_CAPTURE_DURATIONS } from '../../types/audioClip'
import { SCOPE_KINDS } from '../../types/scope'
import type { WindowBackgroundMode, WindowBackgroundState } from '../../types/windowState'
import ThemedSelect from './ThemedSelect'
const SCOPE_LABELS: Record<ScopeKind, string> = {
@@ -43,22 +38,6 @@ const FRAME_TARGET_LABELS: Record<VisualizerFrameTarget, string> = {
const DEFAULT_INPUT_DEVICE_ID = '__default_input__'
const WINDOW_BACKGROUND_MODES: readonly WindowBackgroundMode[] = ['solid', 'blurred', 'clear']
const WINDOW_BACKGROUND_MODE_LABELS: Record<WindowBackgroundMode, string> = {
solid: 'Solid',
blurred: 'Blurred',
clear: 'Clear',
}
const WINDOW_BACKGROUND_MODE_TITLES: Record<WindowBackgroundMode, string> = {
solid: 'Opaque themed background',
blurred: 'Desktop shows through, blurred',
clear: 'Desktop shows through, crisp',
}
const WINDOW_BACKGROUND_SET_THROTTLE_MS = 60
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message
? error.message
@@ -123,32 +102,6 @@ export function resolveThemeCreditDetails(theme: ThemeCreditSource): {
export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
const [isRefreshingThemes, setIsRefreshingThemes] = useState(false)
const windowBackground = useWindowBackgroundStore((s) => s.stored)
const previewWindowBackground = useWindowBackgroundStore((s) => s.previewBackground)
const setWindowBackground = useWindowBackgroundStore((s) => s.setBackground)
const supportsBlurredBackground = getRendererWindowCapabilities().supportsBlurredBackground
const pendingWindowBackgroundRef = useRef<WindowBackgroundState | null>(null)
const windowBackgroundFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const queueWindowBackgroundSave = (next: WindowBackgroundState): void => {
previewWindowBackground(next)
pendingWindowBackgroundRef.current = next
if (windowBackgroundFlushTimerRef.current) return
windowBackgroundFlushTimerRef.current = setTimeout(() => {
windowBackgroundFlushTimerRef.current = null
const pending = pendingWindowBackgroundRef.current
pendingWindowBackgroundRef.current = null
if (pending) {
void setWindowBackground(pending)
}
}, WINDOW_BACKGROUND_SET_THROTTLE_MS)
}
const handleWindowBackgroundMode = (mode: WindowBackgroundMode): void => {
if (mode === windowBackground.mode) return
void setWindowBackground({ ...windowBackground, mode })
}
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
@@ -179,23 +132,13 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
captureError,
captureNotice,
inputGainDb,
rollingCaptureSeconds,
rollingCaptureStatus,
clearCaptureNotice,
selectSystemSource,
selectDevice,
startCapture,
setInputGain,
setRollingCaptureSeconds,
revealRollingCaptureFolder,
} = useAudioStore()
const showBanner = useUiStore((s) => s.showBanner)
const desktopIntegration = useDesktopIntegrationStore((s) => s.snapshot)
const desktopIntegrationBusy = useDesktopIntegrationStore((s) => s.busy)
const desktopIntegrationError = useDesktopIntegrationStore((s) => s.error)
const setCloseToTray = useDesktopIntegrationStore((s) => s.setCloseToTray)
const setOpenAtLogin = useDesktopIntegrationStore((s) => s.setOpenAtLogin)
const setLoginLaunchMode = useDesktopIntegrationStore((s) => s.setLoginLaunchMode)
useLayoutEffect(() => {
if (!onHeightChange || !rootRef.current) return
@@ -315,18 +258,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
}
}
const handleRevealRollingCaptureFolder = async (): Promise<void> => {
try {
await revealRollingCaptureFolder()
} catch (error) {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not open the Prism Captures folder.'),
actions: [],
})
}
}
const handleReloadThemes = async (): Promise<void> => {
if (isRefreshingThemes) {
return
@@ -404,13 +335,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
.map((providerId) => nowPlayingState.definitions[providerId].label)
.join(' → ')}`
const captureMessage = captureError ?? captureNotice
const rollingCaptureStatusLabel = rollingCaptureSeconds === null
? 'Off'
: rollingCaptureStatus.ready
? 'Ready'
: rollingCaptureStatus.hasAudio
? 'Filling'
: 'Waiting'
const nowPlayingErrorMessage = currentNowPlayingProvider?.lastError ?? currentNowPlayingProvider?.lastControlError ?? null
const nowPlayingDetail = nowPlayingErrorMessage
? `${currentNowPlayingProviderId ? `${nowPlayingState.definitions[currentNowPlayingProviderId].label} · ` : ''}${nowPlayingErrorMessage}`
@@ -420,13 +344,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
const canUseDefaultSource = captureMode === 'system'
? selectedSystemSourceId !== defaultSystemSourceId
: selectedDeviceId !== null
const loginItemStatusMessage = desktopIntegration.loginItemStatus === 'requires-approval'
? 'Approval required in system login settings'
: desktopIntegration.loginItemStatus === 'blocked'
? 'Disabled in system startup settings'
: desktopIntegration.loginItemStatus === 'unavailable'
? 'Open at login is available in packaged builds'
: desktopIntegrationError
return (
<div className="bottom-bar" ref={rootRef}>
@@ -524,107 +441,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--window">
<div className="bottom-bar__section-header">
<div className="bottom-bar__section-title">Window</div>
{windowBackground.mode !== 'solid' || loginItemStatusMessage ? (
<span className="bottom-bar__window-metadata">
{windowBackground.mode !== 'solid' ? (
<span className="bottom-bar__window-note">
Window snapping is disabled in this mode
</span>
) : null}
{windowBackground.mode !== 'solid' && loginItemStatusMessage ? (
<span className="bottom-bar__metadata-separator" aria-hidden="true">·</span>
) : null}
{loginItemStatusMessage ? (
<span
className={`${desktopIntegrationError ? 'settings-error-text' : 'settings-info-text'} bottom-bar__desktop-status`.trim()}
role="status"
>
{loginItemStatusMessage}
</span>
) : null}
</span>
) : null}
</div>
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--window">
<div className="bottom-bar__inline bottom-bar__inline--chips">
{WINDOW_BACKGROUND_MODES.map((mode) => {
const unsupported = mode === 'blurred' && !supportsBlurredBackground
return (
<button
key={mode}
type="button"
className={`settings-chip ${windowBackground.mode === mode ? 'is-active' : ''}`.trim()}
onClick={() => handleWindowBackgroundMode(mode)}
disabled={unsupported}
title={unsupported
? 'Blurred background requires Windows 11'
: WINDOW_BACKGROUND_MODE_TITLES[mode]}
>
{WINDOW_BACKGROUND_MODE_LABELS[mode]}
</button>
)
})}
</div>
<span className="bottom-bar__trim-value">
{windowBackground.transparency}%
</span>
<input
className="settings-control__range bottom-bar__trim-slider"
type="range"
min={0}
max={100}
step={1}
value={windowBackground.transparency}
disabled={windowBackground.mode === 'solid'}
style={{ '--range-percent': `${windowBackground.transparency}%` } as CSSProperties}
onChange={(event) => {
queueWindowBackgroundSave({
...windowBackground,
transparency: Number(event.target.value),
})
}}
title="How much of the desktop shows through"
/>
<span className="bottom-bar__inline-divider" aria-hidden="true" />
<button
type="button"
className={`settings-chip ${desktopIntegration.closeToTray ? 'is-active' : ''}`.trim()}
disabled={desktopIntegrationBusy}
onClick={() => void setCloseToTray(!desktopIntegration.closeToTray)}
title="Closing Prism hides every Prism window; use the tray menu to quit"
>
Close to tray
</button>
<button
type="button"
className={`settings-chip ${desktopIntegration.openAtLogin ? 'is-active' : ''}`.trim()}
disabled={desktopIntegrationBusy || desktopIntegration.loginItemStatus === 'unavailable'}
onClick={() => void setOpenAtLogin(!desktopIntegration.openAtLogin)}
>
Open at login
</button>
<ThemedSelect
value={desktopIntegration.loginLaunchMode}
disabled={desktopIntegrationBusy || !desktopIntegration.openAtLogin}
onChange={(event) => {
void setLoginLaunchMode(event.target.value === 'tray' ? 'tray' : 'show')
}}
className="bottom-bar__login-select"
title="What Prism should show when opened automatically at login"
>
<option value="show">Login: Show Prism</option>
<option value="tray">Login: Start in tray</option>
</ThemedSelect>
</div>
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--now-playing">
<div className="bottom-bar__section-header bottom-bar__section-header--now-playing">
<div className="bottom-bar__section-title">Now Playing</div>
@@ -748,55 +564,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--rolling-capture">
<div className="bottom-bar__section-title">Rolling Capture</div>
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--rolling-capture">
<div className="bottom-bar__inline bottom-bar__inline--chips">
<button
type="button"
className={`settings-chip ${rollingCaptureSeconds === null ? 'is-active' : ''}`.trim()}
onClick={() => setRollingCaptureSeconds(null)}
>
Off
</button>
{ROLLING_CAPTURE_DURATIONS.map((duration) => (
<button
key={duration}
type="button"
className={`settings-chip ${rollingCaptureSeconds === duration ? 'is-active' : ''}`.trim()}
onClick={() => setRollingCaptureSeconds(duration)}
>
{duration}s
</button>
))}
</div>
<div
className={`settings-status-pill ${rollingCaptureStatus.ready ? 'is-capturing' : ''}`.trim()}
title={rollingCaptureSeconds === null
? 'Rolling capture uses no recorder memory while off'
: `Keeps up to ${rollingCaptureSeconds} seconds in memory`}
>
<span className="settings-status-pill__dot" />
<span>{rollingCaptureStatusLabel}</span>
</div>
<button
type="button"
className="settings-chip"
onClick={() => {
void handleRevealRollingCaptureFolder()
}}
>
Folder
</button>
</div>
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--performance">
<div className="bottom-bar__section-title">Performance</div>
<div className="bottom-bar__section-body">
@@ -1,221 +0,0 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type JSX,
type PointerEvent as ReactPointerEvent,
type RefObject,
} from 'react'
import type { ScopeDisplayRotation } from '../../types/scopeTransform'
import {
resolveMeasurementReadoutPosition,
resolveMeasurementSourcePoint,
type ActiveScopeMeasurement,
type ScopeMeasurementSource,
} from '../scopeMeasurement'
interface ScopeMeasurementControllerOptions {
containerRef: RefObject<HTMLDivElement | null>
getSource: () => ScopeMeasurementSource | null
enabled: boolean
rotation: ScopeDisplayRotation
mirrorHorizontal: boolean
onActiveChange?: (active: boolean) => void
}
interface ScopeMeasurementPointerBindings {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void
onLostPointerCapture: (event: ReactPointerEvent<HTMLDivElement>) => void
}
export interface ScopeMeasurementController {
active: boolean
measurement: ActiveScopeMeasurement | null
pointerBindings: ScopeMeasurementPointerBindings
}
export function useScopeMeasurement({
containerRef,
getSource,
enabled,
rotation,
mirrorHorizontal,
onActiveChange,
}: ScopeMeasurementControllerOptions): ScopeMeasurementController {
const [measurement, setMeasurement] = useState<ActiveScopeMeasurement | null>(null)
const activePointerIdRef = useRef<number | null>(null)
const getSourceRef = useRef(getSource)
const onActiveChangeRef = useRef(onActiveChange)
const transformRef = useRef({ rotation, mirrorHorizontal })
getSourceRef.current = getSource
onActiveChangeRef.current = onActiveChange
transformRef.current = { rotation, mirrorHorizontal }
const setActive = useCallback((active: boolean): void => {
getSourceRef.current()?.setMeasurementActive?.(active)
onActiveChangeRef.current?.(active)
}, [])
const endMeasurement = useCallback((): void => {
const pointerId = activePointerIdRef.current
if (pointerId === null) return
activePointerIdRef.current = null
const container = containerRef.current
if (container?.hasPointerCapture(pointerId)) {
container.releasePointerCapture(pointerId)
}
setMeasurement(null)
setActive(false)
}, [containerRef, setActive])
const updateMeasurement = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
const container = containerRef.current
const source = getSourceRef.current()
if (!container || !source) return
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const viewportPoint = {
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
}
const sourcePoint = resolveMeasurementSourcePoint(
viewportPoint,
transformRef.current.rotation,
transformRef.current.mirrorHorizontal,
)
setMeasurement({
pointerId: event.pointerId,
viewportPoint,
sourcePoint,
measurement: source.getMeasurementAt(sourcePoint),
})
}, [containerRef])
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (!enabled || event.altKey || event.button !== 0 || !event.isPrimary || activePointerIdRef.current !== null) return
if (!getSourceRef.current()) return
event.preventDefault()
activePointerIdRef.current = event.pointerId
event.currentTarget.setPointerCapture(event.pointerId)
setActive(true)
updateMeasurement(event)
}, [enabled, setActive, updateMeasurement])
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (activePointerIdRef.current !== event.pointerId) return
updateMeasurement(event)
}, [updateMeasurement])
const handlePointerEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (activePointerIdRef.current !== event.pointerId) return
endMeasurement()
}, [endMeasurement])
const handleLostPointerCapture = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (activePointerIdRef.current === event.pointerId) endMeasurement()
}, [endMeasurement])
useEffect(() => {
if (!enabled) endMeasurement()
}, [enabled, endMeasurement])
useEffect(() => {
window.addEventListener('blur', endMeasurement)
return () => {
window.removeEventListener('blur', endMeasurement)
endMeasurement()
}
}, [endMeasurement])
return {
active: measurement !== null,
measurement,
pointerBindings: {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerEnd,
onPointerCancel: handlePointerEnd,
onLostPointerCapture: handleLostPointerCapture,
},
}
}
interface ScopeMeasurementOverlayProps {
containerRef: RefObject<HTMLDivElement | null>
measurement: ActiveScopeMeasurement | null
}
interface Size {
width: number
height: number
}
export function ScopeMeasurementOverlay({
containerRef,
measurement,
}: ScopeMeasurementOverlayProps): JSX.Element | null {
const readoutRef = useRef<HTMLDivElement>(null)
const [readoutSize, setReadoutSize] = useState<Size>({ width: 260, height: 30 })
const [viewportSize, setViewportSize] = useState<Size>({ width: 1, height: 1 })
const active = measurement !== null
useLayoutEffect(() => {
if (!active) return
const readout = readoutRef.current
const container = containerRef.current
if (!readout || !container) return
const measure = (): void => {
const nextReadoutSize = { width: readout.offsetWidth, height: readout.offsetHeight }
setReadoutSize((previous) => previous.width === nextReadoutSize.width
&& previous.height === nextReadoutSize.height
? previous
: nextReadoutSize)
const rect = container.getBoundingClientRect()
const nextViewportSize = { width: rect.width, height: rect.height }
setViewportSize((previous) => previous.width === nextViewportSize.width
&& previous.height === nextViewportSize.height
? previous
: nextViewportSize)
}
measure()
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure)
observer?.observe(readout)
observer?.observe(container)
return () => observer?.disconnect()
}, [active, containerRef])
if (!measurement) return null
const x = measurement.viewportPoint.x * viewportSize.width
const y = measurement.viewportPoint.y * viewportSize.height
const readoutPosition = resolveMeasurementReadoutPosition(
{ x, y },
viewportSize,
readoutSize,
)
const readoutStyle: CSSProperties = {
left: `${readoutPosition.left}px`,
top: `${readoutPosition.top}px`,
}
return (
<div className="scope-measurement" aria-hidden="true">
<span className="scope-measurement__line scope-measurement__line--vertical" style={{ left: `${measurement.viewportPoint.x * 100}%` }} />
<span className="scope-measurement__line scope-measurement__line--horizontal" style={{ top: `${measurement.viewportPoint.y * 100}%` }} />
<div ref={readoutRef} className="scope-measurement__readout" style={readoutStyle}>
{measurement.measurement.values.map((value, index) => (
<span key={`${index}:${value}`} className="scope-measurement__item">
{index > 0 && <span className="scope-measurement__separator">|</span>}
<span className="scope-measurement__value">{value}</span>
</span>
))}
</div>
</div>
)
}
+67 -136
View File
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react'
import { isTransformableScopeKind, type ScopeKind } from '../../types/scope'
import type { ScopeKind } from '../../types/scope'
import type { ScopeSettings } from '../../types/settings'
import { nominalFrequencyBoundsForRange } from '../../types/frequencyScale'
import type { ScopeDisplayRotation } from '../../types/scopeTransform'
import type {
PrismResolvedTheme,
ResolvedAstraTheme,
@@ -17,7 +15,6 @@ import type {
import type { SpectrumPeakInfo } from '../../types/spectrum'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import { useWindowBackgroundAlpha } from '../stores/windowBackgroundStore'
import AstraScopeModule from './AstraScopeModule'
import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer'
import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope'
@@ -27,18 +24,6 @@ import { VUMeter, type VUMeterDataSource } from '../visualizers/VUMeter'
import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter'
import { Waveform, type WaveformDataSource } from '../visualizers/Waveform'
import type { FrameScheduler } from '../visualizers/frameScheduler'
import {
ScopeMeasurementOverlay,
useScopeMeasurement,
} from './ScopeMeasurementOverlay'
import type { ScopeMeasurementSource } from '../scopeMeasurement'
import {
getScopeCanvasTransformStyle,
isSameScopeCanvasLayout,
measureScopeCanvasLayout,
transformNormalizedScopePoint,
type ScopeCanvasLayout,
} from '../scopeCanvasTransform'
type ScopeModuleTheme =
| ResolvedSpectrumTheme
@@ -55,7 +40,6 @@ interface ScopeModuleProps {
theme?: ScopeModuleTheme
settings?: ScopeSettings[ScopeKind]
frameScheduler?: FrameScheduler
onMeasurementActiveChange?: (active: boolean) => void
dataSource?:
| SpectrumAnalyzerDataSource
| OscilloscopeDataSource
@@ -72,8 +56,14 @@ interface Visualizer {
dispose(): void
resize(): void
setOptions(options: Record<string, unknown>): void
getMeasurementAt?: ScopeMeasurementSource['getMeasurementAt']
setMeasurementActive?: ScopeMeasurementSource['setMeasurementActive']
}
interface CanvasResizeState {
cssWidth: number
cssHeight: number
pixelWidth: number
pixelHeight: number
dpr: number
}
const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10
@@ -93,12 +83,12 @@ function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function formatSpectrumPeakDbfs(value: number): string {
function formatSpectrumPeakDb(value: number): string {
if (!Number.isFinite(value)) {
return '--'
}
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dBFS`
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dB`
}
function formatSpectrumPeakFrequency(value: number): string {
@@ -115,10 +105,8 @@ function formatSpectrumPeakFrequency(value: number): string {
function resolveFollowingPeakOverlayStyle(
peakInfo: SpectrumPeakInfo,
resizeState: ScopeCanvasLayout | null,
resizeState: CanvasResizeState | null,
overlaySize: SizeMeasurement | null,
rotation: ScopeDisplayRotation,
mirrorHorizontal: boolean,
): CSSProperties {
if (!resizeState) {
return {
@@ -127,17 +115,12 @@ function resolveFollowingPeakOverlayStyle(
}
}
const width = resizeState.viewportCssWidth
const height = resizeState.viewportCssHeight
const width = resizeState.cssWidth
const height = resizeState.cssHeight
const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX
const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX
const transformedPeak = transformNormalizedScopePoint(
{ x: peakInfo.normalizedX, y: peakInfo.normalizedY },
rotation,
mirrorHorizontal,
)
const peakX = transformedPeak.x * width
const peakY = transformedPeak.y * height
const peakX = peakInfo.normalizedX * width
const peakY = peakInfo.normalizedY * height
const maxLeft = Math.max(
SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
@@ -161,6 +144,36 @@ function resolveFollowingPeakOverlayStyle(
}
}
function measureCanvasResizeState(container: HTMLDivElement): CanvasResizeState {
const rect = container.getBoundingClientRect()
const cssWidth = Math.max(1, Math.floor(rect.width))
const cssHeight = Math.max(1, Math.floor(rect.height))
const dpr = window.devicePixelRatio || 1
return {
cssWidth,
cssHeight,
pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)),
pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)),
dpr,
}
}
function isSameCanvasResizeState(
left: CanvasResizeState | null,
right: CanvasResizeState | null,
): boolean {
if (!left || !right) {
return false
}
return left.cssWidth === right.cssWidth
&& left.cssHeight === right.cssHeight
&& left.pixelWidth === right.pixelWidth
&& left.pixelHeight === right.pixelHeight
&& left.dpr === right.dpr
}
export function scopeSettingsToOptions(
kind: ScopeKind,
settings: ScopeSettings[ScopeKind],
@@ -170,7 +183,6 @@ export function scopeSettingsToOptions(
case 'spectrum': {
const s = settings as ScopeSettings['spectrum']
const t = theme as ResolvedSpectrumTheme
const range = nominalFrequencyBoundsForRange(s.frequencyRangeMode)
return {
lineColor: t.line,
secondaryLineColor: t.sideLine,
@@ -180,9 +192,6 @@ export function scopeSettingsToOptions(
backgroundColor: t.background,
gridColor: t.guides,
labelColor: t.labels,
scaleType: s.scaleMode,
minFrequency: range.minFrequency,
maxFrequency: range.maxFrequency,
fftSize: s.fftSize,
tiltDbPerOctave: s.tiltDbPerOctave,
heatmapFill: s.heatmap,
@@ -218,14 +227,12 @@ export function scopeSettingsToOptions(
gridMajorColor: t.guides,
gridMinorColor: t.guidesSecondary,
labelColor: t.labels,
phaseRiskColor: t.phaseRisk,
bandColors: {
low: t.bandLow,
mid: t.bandMid,
high: t.bandHigh,
},
mode: s.mode,
zoomDb: s.zoomDb,
multiband: s.multiband,
showGrid: s.showGrid,
persistence: s.persistence,
@@ -235,23 +242,17 @@ export function scopeSettingsToOptions(
case 'spectrogram': {
const s = settings as ScopeSettings['spectrogram']
const t = theme as ResolvedSpectrogramTheme
const range = nominalFrequencyBoundsForRange(s.frequencyRangeMode)
return {
lineColor: t.mono,
heatColors: t.heatColors,
backgroundColor: t.background,
gridColor: t.guides,
labelColor: t.labels,
minFrequency: range.minFrequency,
maxFrequency: range.maxFrequency,
fftSize: s.fftSize,
tiltDbPerOctave: s.tiltDbPerOctave,
scrollSpeed: s.scrollSpeed,
contrast: s.contrast,
clarityMode: s.clarityMode,
scaleMode: s.scaleMode,
showGrid: s.showGrid,
orientation: 'horizontal',
orientation: s.orientation,
colorScheme: s.colorScheme,
}
}
@@ -314,27 +315,6 @@ export function scopeSettingsToOptions(
}
}
// In blurred/clear window modes the see-through tint lives in CSS (a stable
// compositor layer on .scope-strip); the canvas paints no background at all so
// per-frame redraws never race the OS backdrop. Traces stay fully opaque.
export function applyWindowBackgroundAlphaToOptions(
options: Record<string, unknown>,
windowBgAlpha: number,
): Record<string, unknown> {
if (
windowBgAlpha >= 1
|| typeof options.backgroundColor !== 'string'
|| options.backgroundColor === 'transparent'
) {
return options
}
return {
...options,
backgroundColor: 'transparent',
}
}
function createVisualizer(
scopeKind: ScopeKind,
canvas: HTMLCanvasElement,
@@ -344,15 +324,8 @@ function createVisualizer(
dataSource?: ScopeModuleProps['dataSource'],
onSpectrumPeakInfo?: (peakInfo: SpectrumPeakInfo | null) => void,
captureSpectrumPeakInfo = false,
windowBgAlpha = 1,
): Visualizer | null {
const opts = {
...applyWindowBackgroundAlphaToOptions(
scopeSettingsToOptions(scopeKind, mySettings, theme),
windowBgAlpha,
),
frameScheduler,
}
const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, theme), frameScheduler }
switch (scopeKind) {
case 'spectrum':
return new SpectrumAnalyzer(canvas, {
@@ -403,15 +376,14 @@ export default function ScopeModule({
theme,
settings,
frameScheduler,
onMeasurementActiveChange,
dataSource,
}: ScopeModuleProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const visualizerRef = useRef<Visualizer | null>(null)
const initializedRef = useRef(false)
const pendingResizeRef = useRef<ScopeCanvasLayout | null>(null)
const appliedResizeRef = useRef<ScopeCanvasLayout | null>(null)
const pendingResizeRef = useRef<CanvasResizeState | null>(null)
const appliedResizeRef = useRef<CanvasResizeState | null>(null)
const resizeFrameRef = useRef<number | null>(null)
const snapshotCanvasRef = useRef<HTMLCanvasElement | null>(null)
const peakOverlayRef = useRef<HTMLDivElement | null>(null)
@@ -420,15 +392,8 @@ export default function ScopeModule({
const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
const activeTheme = useThemeStore((s) => s.activeTheme)
const windowBgAlpha = useWindowBackgroundAlpha()
const mySettings = settings ?? storeSettings
const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind)
const rotation: ScopeDisplayRotation = isTransformableScopeKind(scopeKind)
? (mySettings as ScopeSettings[typeof scopeKind]).rotation
: 0
const mirrorHorizontal = isTransformableScopeKind(scopeKind)
? (mySettings as ScopeSettings[typeof scopeKind]).mirrorHorizontal
: false
const spectrumPeakMode = scopeKind === 'spectrum'
? (mySettings as ScopeSettings['spectrum']).peakInfoMode
: 'off'
@@ -436,23 +401,6 @@ export default function ScopeModule({
const handleSpectrumPeakInfo = useCallback((nextPeakInfo: SpectrumPeakInfo | null): void => {
setSpectrumPeakInfo(nextPeakInfo)
}, [])
const measurementEnabled = scopeKind === 'spectrum'
|| scopeKind === 'spectrogram'
|| scopeKind === 'oscilloscope'
|| scopeKind === 'waveform'
const measurementController = useScopeMeasurement({
containerRef,
enabled: measurementEnabled,
rotation,
mirrorHorizontal,
getSource: () => {
const visualizer = visualizerRef.current
return visualizer?.getMeasurementAt
? visualizer as ScopeMeasurementSource
: null
},
onActiveChange: onMeasurementActiveChange,
})
useEffect(() => {
if (!captureSpectrumPeakInfo) {
@@ -521,7 +469,6 @@ export default function ScopeModule({
dataSource,
handleSpectrumPeakInfo,
captureSpectrumPeakInfo,
windowBgAlpha,
)
if (!viz) return
@@ -538,17 +485,12 @@ export default function ScopeModule({
initializedRef.current = false
setSpectrumPeakInfo(null)
}
// Settings and theme changes are applied live by the setOptions effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSource, frameScheduler, handleSpectrumPeakInfo, scopeKind])
}, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, myTheme, mySettings, scopeKind])
useEffect(() => {
if (!visualizerRef.current || !initializedRef.current) return
const opts = {
...applyWindowBackgroundAlphaToOptions(
scopeSettingsToOptions(scopeKind, mySettings, myTheme),
windowBgAlpha,
),
...scopeSettingsToOptions(scopeKind, mySettings, myTheme),
frameScheduler,
...(scopeKind === 'spectrum'
? {
@@ -559,9 +501,9 @@ export default function ScopeModule({
...(dataSource ? { dataSource } : {}),
}
visualizerRef.current.setOptions(opts)
}, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, mySettings, myTheme, scopeKind, windowBgAlpha])
}, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, mySettings, myTheme, scopeKind])
useLayoutEffect(() => {
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
@@ -576,7 +518,7 @@ export default function ScopeModule({
const applyResize = (): void => {
resizeFrameRef.current = null
const nextResize = pendingResizeRef.current
if (!nextResize || isSameScopeCanvasLayout(appliedResizeRef.current, nextResize)) {
if (!nextResize || isSameCanvasResizeState(appliedResizeRef.current, nextResize)) {
return
}
@@ -617,7 +559,7 @@ export default function ScopeModule({
}
const scheduleResize = (): void => {
pendingResizeRef.current = measureScopeCanvasLayout(container, rotation)
pendingResizeRef.current = measureCanvasResizeState(container)
if (resizeFrameRef.current !== null) {
return
}
@@ -627,7 +569,7 @@ export default function ScopeModule({
})
}
pendingResizeRef.current = measureScopeCanvasLayout(container, rotation)
pendingResizeRef.current = measureCanvasResizeState(container)
applyResize()
const observer = new ResizeObserver(() => {
@@ -645,29 +587,18 @@ export default function ScopeModule({
appliedResizeRef.current = null
snapshotCanvasRef.current = null
}
}, [rotation])
}, [])
const spectrumPeakOverlayStyle = scopeKind === 'spectrum'
&& spectrumPeakMode === 'following'
&& spectrumPeakInfo
? resolveFollowingPeakOverlayStyle(
spectrumPeakInfo,
appliedResizeRef.current,
peakOverlaySize,
rotation,
mirrorHorizontal,
)
? resolveFollowingPeakOverlayStyle(spectrumPeakInfo, appliedResizeRef.current, peakOverlaySize)
: undefined
return (
<div
className={[
'scope-module',
measurementEnabled ? 'scope-measurement-surface' : '',
measurementController.active ? 'is-measuring' : '',
].filter(Boolean).join(' ')}
className="scope-module"
ref={containerRef}
{...measurementController.pointerBindings}
style={{
minWidth: 0,
height: '100%',
@@ -678,14 +609,14 @@ export default function ScopeModule({
<canvas
ref={canvasRef}
style={{
...getScopeCanvasTransformStyle(rotation, mirrorHorizontal),
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}}
/>
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
{!measurementController.active && scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
{scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
<div
ref={spectrumPeakMode === 'following' ? peakOverlayRef : null}
className={[
@@ -694,7 +625,7 @@ export default function ScopeModule({
].join(' ')}
style={spectrumPeakOverlayStyle}
>
<span className="scope-module__peak-info-value">{formatSpectrumPeakDbfs(spectrumPeakInfo.dbfs)}</span>
<span className="scope-module__peak-info-value">{formatSpectrumPeakDb(spectrumPeakInfo.db)}</span>
<span className="scope-module__peak-info-separator">/</span>
<span className="scope-module__peak-info-value">{formatSpectrumPeakFrequency(spectrumPeakInfo.frequencyHz)}</span>
<span className="scope-module__peak-info-separator">/</span>
@@ -39,7 +39,7 @@ function flushScopeAudioBatch(kind: AudioScopeKind, scopeSettings: ScopeSettings
case 'vectorscope':
return audioRouter.flushPendingVectorscopeSamples()
case 'spectrogram':
return audioRouter.flushPendingSpectrogramStereoSamples()
return audioRouter.flushPendingSpectrogramSamples()
case 'vumeter':
return audioRouter.flushPendingVUMeterSamples()
case 'lufsmeter':
+29 -127
View File
@@ -1,12 +1,9 @@
import { useState, type CSSProperties, type JSX, type ReactNode } from 'react'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_LABELS, isTransformableScopeKind } from '../../types/scope'
import { SCOPE_LABELS } from '../../types/scope'
import type { ScopeSettings } from '../../types/settings'
import { frequencyRangeLabel } from '../../types/frequencyScale'
import { SCOPE_DISPLAY_ROTATIONS, type ScopeDisplayRotation } from '../../types/scopeTransform'
import {
MAX_SPECTROGRAM_CONTRAST,
MAX_SPECTROGRAM_SCROLL_SPEED,
MAX_SPECTROGRAM_TILT_DB_PER_OCTAVE,
MIN_SPECTROGRAM_CONTRAST,
MIN_SPECTROGRAM_TILT_DB_PER_OCTAVE,
@@ -26,26 +23,20 @@ import {
MIN_WAVEFORM_SCROLL_SPEED,
WAVEFORM_SCROLL_SPEED_STEP,
} from '../../types/waveform'
import {
formatVectorscopeZoomDb,
MAX_VECTORSCOPE_ZOOM_DB,
MIN_VECTORSCOPE_ZOOM_DB,
VECTORSCOPE_ZOOM_STEP_DB,
} from '../../types/vectorscope'
import ThemedSelect from './ThemedSelect'
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
switch (mode) {
case 'lissajous':
return 'XY (L/R)'
return 'Lissajous'
case 'polar-unipolar':
return 'Polar (Folded)'
return 'Polar Uni'
case 'polar-bipolar':
return 'Polar (Bipolar)'
return 'Polar Bi'
case 'linear-unipolar':
return 'M/S Linear (Folded)'
return 'Linear Uni'
case 'linear-bipolar':
return 'M/S Linear (Bipolar)'
return 'Linear Bi'
}
}
@@ -71,20 +62,11 @@ function nowPlayingVisibleLabels(settings: ScopeSettings['nowPlaying']): string[
return labels
}
function appendTransformSummary(
parts: string[],
settings: { rotation: ScopeDisplayRotation; mirrorHorizontal: boolean },
): string {
if (settings.rotation !== 0) parts.push(`R${settings.rotation}°`)
if (settings.mirrorHorizontal) parts.push('Mirror')
return parts.join(' · ')
}
export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
switch (kind) {
case 'spectrum': {
const scopeSettings = settings as ScopeSettings['spectrum']
const summary = `${scopeSettings.scaleMode.toUpperCase()} · ${frequencyRangeLabel(scopeSettings.frequencyRangeMode)} · ${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
const summary = `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
const parts = [summary]
if (scopeSettings.showSideLine) {
parts.push('Side')
@@ -94,32 +76,22 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
} else if (scopeSettings.peakInfoMode === 'following') {
parts.push('Peak Follow')
}
return appendTransformSummary(parts, scopeSettings)
return parts.join(' · ')
}
case 'oscilloscope': {
const scopeSettings = settings as ScopeSettings['oscilloscope']
const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run'
return appendTransformSummary(
scopeSettings.underfillEnabled ? [mode, 'Fill'] : [mode],
scopeSettings,
)
return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode
}
case 'vectorscope': {
const scopeSettings = settings as ScopeSettings['vectorscope']
const parts = [vectorscopeModeLabel(scopeSettings.mode)]
if (scopeSettings.zoomDb !== 0) parts.push(`Zoom ${formatVectorscopeZoomDb(scopeSettings.zoomDb)}`)
if (scopeSettings.multiband) parts.push('RGB')
return parts.join(' · ')
return scopeSettings.multiband
? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB`
: vectorscopeModeLabel(scopeSettings.mode)
}
case 'spectrogram': {
const scopeSettings = settings as ScopeSettings['spectrogram']
return [
`${scopeSettings.rotation}°`,
scopeSettings.scaleMode.toUpperCase(),
frequencyRangeLabel(scopeSettings.frequencyRangeMode),
scopeSettings.clarityMode,
...(scopeSettings.mirrorHorizontal ? ['Mirror'] : []),
].join(' · ')
return `${scopeSettings.orientation.toUpperCase()} · ${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
}
case 'vumeter': {
const scopeSettings = settings as ScopeSettings['vumeter']
@@ -140,7 +112,7 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
if (scopeSettings.multiband) {
summary.push('RGB')
}
return appendTransformSummary(summary, scopeSettings)
return summary.join(' · ')
}
case 'nowPlaying': {
const visible = nowPlayingVisibleLabels(settings as ScopeSettings['nowPlaying'])
@@ -343,29 +315,6 @@ export default function ScopeSettingsSection({
))}
</SelectControl>
<SelectControl
label="Scale"
value={current.scaleMode}
onChange={(value) => onUpdate('spectrum', {
scaleMode: value as ScopeSettings['spectrum']['scaleMode'],
})}
>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</SelectControl>
<SelectControl
label="Range"
value={current.frequencyRangeMode}
onChange={(value) => onUpdate('spectrum', {
frequencyRangeMode: value as ScopeSettings['spectrum']['frequencyRangeMode'],
})}
>
<option value="extended">Extended (10 Hz–up to 24 kHz)</option>
<option value="audible">Audible (20 Hz–20 kHz)</option>
</SelectControl>
<SelectControl
label="Peak"
value={current.peakInfoMode}
@@ -495,24 +444,13 @@ export default function ScopeSettingsSection({
value={current.mode}
onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })}
>
<option value="lissajous">XY (L/R)</option>
<option value="polar-unipolar">Polar (Folded)</option>
<option value="polar-bipolar">Polar (Bipolar)</option>
<option value="linear-unipolar">M/S Linear (Folded)</option>
<option value="linear-bipolar">M/S Linear (Bipolar)</option>
<option value="lissajous">Lissajous</option>
<option value="polar-unipolar">Polar (Uni)</option>
<option value="polar-bipolar">Polar (Bi)</option>
<option value="linear-unipolar">Linear (Uni)</option>
<option value="linear-bipolar">Linear (Bi)</option>
</SelectControl>
<RangeControl
label="Zoom"
value={current.zoomDb}
valueLabel={formatVectorscopeZoomDb(current.zoomDb)}
min={MIN_VECTORSCOPE_ZOOM_DB}
max={MAX_VECTORSCOPE_ZOOM_DB}
step={VECTORSCOPE_ZOOM_STEP_DB}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { zoomDb: value })}
/>
<ToggleGroup label="Overlays">
<ToggleChip
label="RGB"
@@ -577,28 +515,25 @@ export default function ScopeSettingsSection({
<option value="linear">Linear</option>
</SelectControl>
<SelectControl
label="Orientation"
value={current.orientation}
onChange={(value) => onUpdate('spectrogram', { orientation: value as ScopeSettings['spectrogram']['orientation'] })}
>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</SelectControl>
<SelectControl
label="Clarity"
value={current.clarityMode}
onChange={(value) => onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })}
>
<option value="classic">Classic</option>
<option value="focused">Focused</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</SelectControl>
<SelectControl
label="Range"
value={current.frequencyRangeMode}
onChange={(value) => onUpdate('spectrogram', {
frequencyRangeMode: value as ScopeSettings['spectrogram']['frequencyRangeMode'],
})}
>
<option value="extended">Extended (10 Hz–up to 24 kHz)</option>
<option value="audible">Audible (20 Hz–20 kHz)</option>
</SelectControl>
<SelectControl
label="Color"
value={current.colorScheme}
@@ -608,20 +543,12 @@ export default function ScopeSettingsSection({
<option value="mono">Solid</option>
</SelectControl>
<ToggleGroup label="Overlay">
<ToggleChip
label="Frequency Grid"
active={current.showGrid}
onClick={() => onUpdate('spectrogram', { showGrid: !current.showGrid })}
/>
</ToggleGroup>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={MAX_SPECTROGRAM_SCROLL_SPEED}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('spectrogram', { scrollSpeed: value })}
@@ -794,31 +721,6 @@ export default function ScopeSettingsSection({
</ToggleGroup>
)
})()}
{isTransformableScopeKind(kind) && (() => {
const current = settings as ScopeSettings[typeof kind]
return (
<>
<SelectControl
label="Rotate"
value={current.rotation}
onChange={(value) => onUpdate(kind, { rotation: Number(value) as ScopeDisplayRotation })}
>
{SCOPE_DISPLAY_ROTATIONS.map((rotation) => (
<option key={rotation} value={rotation}>{rotation}°</option>
))}
</SelectControl>
<ToggleGroup label="Transform">
<ToggleChip
label="Mirror Horizontal"
active={current.mirrorHorizontal}
onClick={() => onUpdate(kind, { mirrorHorizontal: !current.mirrorHorizontal })}
/>
</ToggleGroup>
</>
)
})()}
</div>
</section>
)
+2 -8
View File
@@ -8,13 +8,8 @@ import { audioRouter } from '../audio/AudioRouter'
import { usePerformanceStore } from '../stores/performanceStore'
import { FrameScheduler } from '../visualizers/frameScheduler'
import { getRendererWindowCapabilities } from '../windowCapabilities'
import { getScopePopoutMinWidth } from '../../shared/scopeSizing'
interface StripProps {
onMeasurementActiveChange?: (active: boolean) => void
}
export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.Element {
export default function Strip(): JSX.Element {
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const scopePopouts = useSettingsStore((s) => s.scopePopouts)
@@ -210,7 +205,7 @@ export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.El
nextBounds = {
x: Math.round(windowBounds.x + rect.left),
y: Math.round(windowBounds.y + rect.top),
width: Math.max(getScopePopoutMinWidth(kind), Math.round(rect.width)),
width: Math.max(220, Math.round(rect.width)),
height: Math.max(160, Math.round(rect.height)),
}
}
@@ -273,7 +268,6 @@ export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.El
<ScopeModule
scopeKind={kind}
frameScheduler={frameScheduler}
onMeasurementActiveChange={onMeasurementActiveChange}
/>
</div>
))}
+2 -39
View File
@@ -1,17 +1,8 @@
import {
useState,
useEffect,
useCallback,
useRef,
type DragEvent as ReactDragEvent,
type JSX,
type PointerEvent as ReactPointerEvent,
} from 'react'
import { useState, useEffect, useCallback, useRef, type JSX, type PointerEvent as ReactPointerEvent } from 'react'
import type { AppBuildInfo } from '../../types/appBuildInfo'
import { useSettingsStore } from '../stores/settingsStore'
import { useUpdateStore } from '../stores/updateStore'
import { useUiStore } from '../stores/uiStore'
import { useAudioStore } from '../stores/audioStore'
import { getRendererWindowCapabilities } from '../windowCapabilities'
import PrismLogo from './PrismLogo'
@@ -180,9 +171,6 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
const latestTag = useUpdateStore((s) => s.latestTag)
const releaseName = useUpdateStore((s) => s.releaseName)
const openReleasesPage = useUpdateStore((s) => s.openReleasesPage)
const rollingCaptureSeconds = useAudioStore((s) => s.rollingCaptureSeconds)
const rollingCaptureStatus = useAudioStore((s) => s.rollingCaptureStatus)
const startRollingClipDrag = useAudioStore((s) => s.startRollingClipDrag)
const [appBuildInfo, setAppBuildInfo] = useState<AppBuildInfo | null>(null)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
const [showReposition, setShowReposition] = useState(false)
@@ -452,11 +440,6 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
})
}, [activeProfileId, profiles])
const handleAudioClipDragStart = useCallback((event: ReactDragEvent<HTMLButtonElement>) => {
event.preventDefault()
startRollingClipDrag()
}, [startRollingClipDrag])
const handleDragStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
if (useNativeDragRegions || event.button !== 0) return
event.preventDefault()
@@ -553,25 +536,6 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
</button>
</div>
{rollingCaptureSeconds !== null ? (
<button
type="button"
className={`toolbar__clip-chip ${rollingCaptureStatus.ready ? 'is-ready' : 'is-filling'}`.trim()}
draggable={rollingCaptureStatus.hasAudio}
disabled={!rollingCaptureStatus.hasAudio}
onDragStart={handleAudioClipDragStart}
title={rollingCaptureStatus.ready
? `Drag the latest ${rollingCaptureSeconds} seconds as a WAV file`
: rollingCaptureStatus.hasAudio
? `Buffer filling; drag the audio captured so far (up to ${rollingCaptureSeconds} seconds)`
: 'Waiting for captured audio'}
aria-label={`Drag the latest ${rollingCaptureSeconds} seconds as a WAV file`}
>
<span className="toolbar__clip-dot" aria-hidden="true" />
<span className="toolbar__clip-prefix">Clip </span>{rollingCaptureSeconds}s
</button>
) : null}
<div className="toolbar__spacer" />
<div className="toolbar__actions">
@@ -621,11 +585,10 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
<button
type="button"
className={`toolbar__icon-button toolbar__icon-button--pin ${isAlwaysOnTop ? 'is-active' : ''}`.trim()}
className={`toolbar__icon-button ${isAlwaysOnTop ? 'is-active' : ''}`.trim()}
onClick={handlePin}
title={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
aria-label={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
aria-pressed={isAlwaysOnTop}
>
<PinIcon />
</button>
@@ -1,143 +0,0 @@
import { useEffect, type JSX } from 'react'
import type { TrayRendererCommand, TrayRendererState } from '../../types/desktopIntegration'
import { useAudioStore } from '../stores/audioStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useUiStore } from '../stores/uiStore'
interface TrayControlBridgeProps {
ready: boolean
}
const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__'
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
async function handleTrayCommand(command: TrayRendererCommand): Promise<void> {
if (command.type === 'open-settings') {
useUiStore.getState().setSettingsOpen(true)
return
}
if (command.type === 'load-profile') {
const settings = useSettingsStore.getState()
try {
await settings.guardProfileTransition(async () => {
await useSettingsStore.getState().loadProfile(command.profileId)
})
} catch (error) {
useUiStore.getState().showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not load the profile.'),
actions: [],
})
}
return
}
const audio = useAudioStore.getState()
if (command.type === 'select-system-source') {
await audio.selectSystemSource(command.sourceId)
await useAudioStore.getState().startCapture()
return
}
if (command.type === 'select-input-source') {
await audio.selectDevice(command.deviceId)
await useAudioStore.getState().startCapture()
return
}
if (command.type === 'set-rolling-capture') {
audio.setRollingCaptureSeconds(command.durationSeconds)
return
}
if (command.type === 'set-capture-running') {
if (command.running) {
await audio.startCapture()
} else {
audio.stopCapture()
}
}
}
export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JSX.Element | null {
const profiles = useSettingsStore((state) => state.profiles)
const activeProfileId = useSettingsStore((state) => state.activeProfileId)
const hasUnsavedProfileChanges = useSettingsStore((state) => state.hasUnsavedProfileChanges)
const captureStatus = useAudioStore((state) => state.captureStatus)
const activeSourceLabel = useAudioStore((state) => state.activeSourceLabel)
const captureMode = useAudioStore((state) => state.captureMode)
const selectedSystemSourceId = useAudioStore((state) => state.selectedSystemSourceId)
const selectedDeviceId = useAudioStore((state) => state.selectedDeviceId)
const rollingCaptureSeconds = useAudioStore((state) => state.rollingCaptureSeconds)
const systemSources = useAudioStore((state) => state.systemSources)
const devices = useAudioStore((state) => state.devices)
useEffect(() => {
const unsubscribe = window.electronAPI.trayControls.onCommand((command) => {
void handleTrayCommand(command).catch((error) => {
useUiStore.getState().showBanner({
tone: 'error',
message: getErrorMessage(error, 'The tray action could not be completed.'),
actions: [],
})
})
})
return unsubscribe
}, [])
useEffect(() => {
if (!ready) {
window.electronAPI.trayControls.markNotReady()
return
}
window.electronAPI.trayControls.markReady()
return () => window.electronAPI.trayControls.markNotReady()
}, [ready])
useEffect(() => {
const visibleSystemSources = systemSources.length > 0
? systemSources
: [{ id: DEFAULT_SYSTEM_SOURCE_ID, label: 'Default Output', isDefault: true }]
const state: TrayRendererState = {
profiles: Object.entries(profiles).map(([id, profile]) => ({ id, name: profile.name })),
activeProfileId,
hasUnsavedProfileChanges,
captureStatus,
activeSourceLabel,
captureMode,
selectedSystemSourceId,
selectedDeviceId,
rollingCaptureSeconds,
systemSources: visibleSystemSources.map((source) => ({
id: source.id,
label: source.label,
isDefault: source.isDefault,
})),
inputSources: [
{ id: '', label: 'Default Input', isDefault: true },
...devices
.filter((device) => device.deviceId !== 'default')
.map((device) => ({
id: device.deviceId,
label: device.label || `Input ${device.deviceId.slice(0, 8)}`,
})),
],
}
window.electronAPI.trayControls.publishState(state)
}, [
activeProfileId,
activeSourceLabel,
captureMode,
captureStatus,
devices,
hasUnsavedProfileChanges,
profiles,
rollingCaptureSeconds,
selectedDeviceId,
selectedSystemSourceId,
systemSources,
])
return null
}
-29
View File
@@ -2,7 +2,6 @@
import type { VisualizerDSP } from './audio/native/visualizer-dsp'
import type { AppBuildInfo } from '../types/appBuildInfo'
import type { AudioClipDragPayload } from '../types/audioClip'
import type { CaptureBackendSupport } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type {
@@ -36,13 +35,6 @@ import type { DialogOptions, DialogResult } from '../types/dialog'
import type { UpdateCheckResult } from '../types/updates'
import type { WindowCapabilities } from '../types/windowCapabilities'
import type { ResizeDirection } from '../types/windowResize'
import type { WindowBackgroundSnapshot, WindowBackgroundState } from '../types/windowState'
import type {
DesktopIntegrationSnapshot,
LoginLaunchMode,
TrayRendererCommand,
TrayRendererState,
} from '../types/desktopIntegration'
declare global {
interface Window {
@@ -54,19 +46,6 @@ declare global {
getAppBuildInfo: () => Promise<AppBuildInfo>
minimize: () => void
close: () => void
desktopIntegration: {
get: () => Promise<DesktopIntegrationSnapshot>
setCloseToTray: (enabled: boolean) => Promise<DesktopIntegrationSnapshot>
setOpenAtLogin: (enabled: boolean) => Promise<DesktopIntegrationSnapshot>
setLoginLaunchMode: (mode: LoginLaunchMode) => Promise<DesktopIntegrationSnapshot>
onChanged: (callback: (snapshot: DesktopIntegrationSnapshot) => void) => () => void
}
trayControls: {
markReady: () => void
markNotReady: () => void
publishState: (state: TrayRendererState) => void
onCommand: (callback: (command: TrayRendererCommand) => void) => () => void
}
startWindowMove: () => void
stopWindowMove: () => void
startWindowResize: (edge: ResizeDirection) => void
@@ -76,15 +55,8 @@ declare global {
repositionWindow: (position: 'top' | 'bottom') => void
toggleAlwaysOnTop: () => void
isAlwaysOnTop: () => Promise<boolean>
getWindowBackground: () => Promise<WindowBackgroundSnapshot>
setWindowBackground: (state: WindowBackgroundState) => Promise<WindowBackgroundSnapshot>
isCursorInsideWindow: () => Promise<boolean>
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
audioClips: {
startDrag: (payload: AudioClipDragPayload) => void
revealFolder: () => Promise<void>
onDragError: (callback: (message: string) => void) => () => void
}
getNowPlayingState: () => Promise<NowPlayingState>
setNowPlayingConsumerActive: (active: boolean) => Promise<NowPlayingState>
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => Promise<NowPlayingState>
@@ -130,7 +102,6 @@ declare global {
requestScopePopIn: (kind: ScopeKind) => void
sendScopePopoutSettingsUpdate: (kind: ScopeKind, partial: unknown) => void
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void
onWindowBackgroundChanged: (callback: (snapshot: WindowBackgroundSnapshot) => void) => () => void
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => () => void
onNowPlayingStateChanged: (callback: (state: NowPlayingState) => void) => () => void
onMainCloseRequested: (callback: () => void) => () => void
-7
View File
@@ -9,10 +9,7 @@ import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/latin-600.css'
import '@fontsource/jetbrains-mono/latin-700.css'
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
import { bootstrapWindowBackgroundFromQuery } from './windowBackground'
function isScopeKind(value: string | null): value is ScopeKind {
return value !== null && SCOPE_KINDS.includes(value as ScopeKind)
@@ -23,10 +20,6 @@ const windowMode = params.get('mode')
const windowRole = params.get('window')
const scopeKind = params.get('scope')
if (windowMode !== 'dialog' && windowRole !== 'now-playing-config') {
bootstrapWindowBackgroundFromQuery()
}
let root: React.ReactElement
if (windowMode === 'dialog') {
root = <DialogApp />
@@ -35,7 +35,7 @@ function isStereoBatch(batch: ScopePopoutAudioBatch): batch is ScopePopoutStereo
}
function isStereoScope(kind: ScopeKind): boolean {
return kind === 'spectrogram' || kind === 'vectorscope' || kind === 'vumeter' || kind === 'lufsmeter'
return kind === 'vectorscope' || kind === 'vumeter' || kind === 'lufsmeter'
}
export class ScopePopoutDataSource implements AnyScopeDataSource {
@@ -131,12 +131,6 @@ export class ScopePopoutDataSource implements AnyScopeDataSource {
return this.scopeKind === 'spectrogram' ? batch : []
}
getPendingSpectrogramStereoSamples(): ScopePopoutStereoBatch {
const batch = this.stereoQueue
this.stereoQueue = []
return this.scopeKind === 'spectrogram' ? batch : []
}
getPendingWaveformSamples(): Float32Array[] {
const batch = this.monoQueue
this.monoQueue = []
+1 -16
View File
@@ -5,10 +5,8 @@ import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings
import { applyResolvedThemeToDocument, createDefaultTheme, resolveTheme } from '../../shared/themeState'
import ScopeModule from '../components/ScopeModule'
import ScopeSettingsSection from '../components/ScopeSettingsSection'
import WindowResizeOverlay from '../components/WindowResizeOverlay'
import { usePerformanceStore } from '../stores/performanceStore'
import { useUiStore } from '../stores/uiStore'
import { useWindowBackgroundStore } from '../stores/windowBackgroundStore'
import { getRendererWindowCapabilities } from '../windowCapabilities'
import { ScopePopoutDataSource } from './ScopePopoutDataSource'
import { FrameScheduler } from '../visualizers/frameScheduler'
@@ -62,7 +60,6 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
const [snapshot, setSnapshot] = useState<ScopePopoutSnapshot<ScopeKind> | null>(null)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
const [cursorInsideWindow, setCursorInsideWindow] = useState(false)
const [measurementActive, setMeasurementActive] = useState(false)
const prevMiniSettingsOpenRef = useRef(false)
const frameTarget = usePerformanceStore((s) => s.frameTarget)
const miniSettingsOpen = useUiStore((s) => s.settingsOpen)
@@ -71,19 +68,12 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind])
const useWindowManagerDragRegions = getRendererWindowCapabilities().useNativeDragRegions
const initializeWindowBackground = useWindowBackgroundStore((s) => s.initialize)
const windowBackgroundMode = useWindowBackgroundStore((s) => s.effective.mode)
useEffect(() => {
void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
return unsubscribe
}, [])
useEffect(() => {
void initializeWindowBackground()
}, [initializeWindowBackground])
useEffect(() => {
frameScheduler.setFrameTarget(frameTarget)
}, [frameScheduler, frameTarget])
@@ -231,7 +221,6 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
'scope-popout__chrome',
miniSettingsOpen ? 'is-expanded' : '',
cursorInsideWindow ? 'is-cursor-inside' : '',
measurementActive ? 'is-measuring' : '',
].join(' ').trim()}
>
<header className={`scope-popout__header ${useWindowManagerDragRegions ? 'is-native-drag' : ''}`.trim()}>
@@ -259,11 +248,10 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
<div className="scope-popout__actions">
<button
type="button"
className={`scope-popout__button scope-popout__button--pin ${isAlwaysOnTop ? 'is-active' : ''}`.trim()}
className={`scope-popout__button ${isAlwaysOnTop ? 'is-active' : ''}`.trim()}
onClick={() => window.electronAPI.toggleAlwaysOnTop()}
aria-label={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
title={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
aria-pressed={isAlwaysOnTop}
>
<PinIcon />
</button>
@@ -297,7 +285,6 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
settings={effectiveSettings}
frameScheduler={frameScheduler}
dataSource={dataSource}
onMeasurementActiveChange={setMeasurementActive}
/>
</div>
</div>
@@ -317,8 +304,6 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
</div>
</div>
)}
{windowBackgroundMode !== 'solid' && <WindowResizeOverlay />}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More