oscilloscope VST

This commit is contained in:
Boof2015
2026-05-27 14:28:20 -04:00
parent 60949a9389
commit b877541882
19 changed files with 786 additions and 286 deletions
+64 -53
View File
@@ -6,7 +6,7 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# JUCE: use a local checkout if provided (-DJUCE_PATH=/path/to/JUCE), otherwise
# fetch a pinned release. A local checkout makes reconfigures much faster.
# fetch a pinned release.
if(DEFINED JUCE_PATH)
add_subdirectory(${JUCE_PATH} juce-build)
else()
@@ -21,65 +21,76 @@ endif()
# Reused, unmodified Prism DSP (no N-API dependency).
set(PRISM_NATIVE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../native/src)
juce_add_plugin(PrismSpectrum
PRODUCT_NAME "Prism Spectrum"
COMPANY_NAME "Prism"
BUNDLE_ID com.astra.prism.spectrum
PLUGIN_MANUFACTURER_CODE Prsm
PLUGIN_CODE Pspc
FORMATS AU VST3 Standalone
IS_SYNTH FALSE
NEEDS_MIDI_INPUT FALSE
NEEDS_MIDI_OUTPUT FALSE
IS_MIDI_EFFECT FALSE
NEEDS_WEBVIEW2 FALSE # macOS uses WKWebView; flip on for Windows later
COPY_PLUGIN_AFTER_BUILD TRUE)
target_sources(PrismSpectrum PRIVATE
Source/PluginProcessor.cpp
Source/PluginEditor.cpp
${PRISM_NATIVE_DIR}/spectrum.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(PrismSpectrum PRIVATE
Source
${PRISM_NATIVE_DIR})
# macOS: private-API helper to lift WKWebView's 60fps cap (no public alternative).
if(APPLE)
target_sources(PrismSpectrum PRIVATE Source/WebViewFrameRate.mm)
set_source_files_properties(Source/WebViewFrameRate.mm PROPERTIES COMPILE_FLAGS "-fno-objc-arc")
target_link_libraries(PrismSpectrum PRIVATE "-framework WebKit")
endif()
target_compile_definitions(PrismSpectrum PRIVATE
JUCE_WEB_BROWSER=1 # enables WebBrowserComponent (WKWebView on macOS)
JUCE_USE_CURL=0
JUCE_VST3_CAN_REPLACE_VST2=0)
# UI delivery: bundled (self-contained, default) vs Vite dev server (hot reload).
option(PRISM_DEV_SERVER "Load the webview UI from the Vite dev server instead of the embedded bundle" OFF)
if(PRISM_DEV_SERVER)
target_compile_definitions(PrismSpectrum PRIVATE PRISM_USE_DEV_SERVER=1)
message(STATUS "Prism: UI from Vite dev server (run 'npm run plugin-ui:dev')")
else()
# Embed the built webview bundle (run 'npm run plugin-ui:build' first).
# Embed the built webview bundle once; every scope plugin shares it (the C++ tells
# the UI which scope to mount via initialisation data).
if(NOT PRISM_DEV_SERVER)
set(PRISM_WEBUI_DIST ${CMAKE_CURRENT_SOURCE_DIR}/webview-dist)
file(GLOB_RECURSE PRISM_WEBUI_FILES "${PRISM_WEBUI_DIST}/*")
if(NOT PRISM_WEBUI_FILES)
message(FATAL_ERROR "No webview bundle at ${PRISM_WEBUI_DIST}. Run: npm run plugin-ui:build")
endif()
juce_add_binary_data(PrismSpectrumWebUI SOURCES ${PRISM_WEBUI_FILES})
target_link_libraries(PrismSpectrum PRIVATE PrismSpectrumWebUI)
target_compile_definitions(PrismSpectrum PRIVATE PRISM_USE_DEV_SERVER=0)
message(STATUS "Prism: UI embedded from ${PRISM_WEBUI_DIST}")
juce_add_binary_data(PrismWebUI SOURCES ${PRISM_WEBUI_FILES})
endif()
target_link_libraries(PrismSpectrum PRIVATE
juce::juce_audio_utils
juce::juce_gui_extra
PRIVATE
juce::juce_recommended_config_flags
juce::juce_recommended_lto_flags
juce::juce_recommended_warning_flags)
# Define one per-scope plugin product from the shared codebase.
# SCOPE_DEFINE selects the ScopeEngine at compile time (empty = spectrum default).
function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE)
juce_add_plugin(${TARGET}
PRODUCT_NAME ${PRODUCT}
COMPANY_NAME "Prism"
BUNDLE_ID com.astra.prism.${TARGET}
PLUGIN_MANUFACTURER_CODE Prsm
PLUGIN_CODE ${PLUGIN_CODE}
FORMATS AU VST3 Standalone
IS_SYNTH FALSE
NEEDS_MIDI_INPUT FALSE
NEEDS_MIDI_OUTPUT FALSE
IS_MIDI_EFFECT FALSE
NEEDS_WEBVIEW2 FALSE # macOS uses WKWebView; flip on for Windows later
COPY_PLUGIN_AFTER_BUILD TRUE)
target_sources(${TARGET} PRIVATE
Source/PluginProcessor.cpp
Source/PluginEditor.cpp
${PRISM_NATIVE_DIR}/spectrum.cpp
${PRISM_NATIVE_DIR}/oscilloscope.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR})
target_compile_definitions(${TARGET} PRIVATE
JUCE_WEB_BROWSER=1 # enables WebBrowserComponent (WKWebView on macOS)
JUCE_USE_CURL=0
JUCE_VST3_CAN_REPLACE_VST2=0)
if(NOT SCOPE_DEFINE STREQUAL "")
target_compile_definitions(${TARGET} PRIVATE ${SCOPE_DEFINE})
endif()
# macOS: private-API helper to lift WKWebView's 60fps cap (no public alternative).
if(APPLE)
target_sources(${TARGET} PRIVATE Source/WebViewFrameRate.mm)
set_source_files_properties(Source/WebViewFrameRate.mm PROPERTIES COMPILE_FLAGS "-fno-objc-arc")
target_link_libraries(${TARGET} PRIVATE "-framework WebKit")
endif()
if(PRISM_DEV_SERVER)
target_compile_definitions(${TARGET} PRIVATE PRISM_USE_DEV_SERVER=1)
else()
target_link_libraries(${TARGET} PRIVATE PrismWebUI)
target_compile_definitions(${TARGET} PRIVATE PRISM_USE_DEV_SERVER=0)
endif()
target_link_libraries(${TARGET} PRIVATE
juce::juce_audio_utils
juce::juce_gui_extra
PRIVATE
juce::juce_recommended_config_flags
juce::juce_recommended_lto_flags
juce::juce_recommended_warning_flags)
endfunction()
add_prism_scope(PrismSpectrum "Prism Spectrum" Pspc "")
add_prism_scope(PrismOscilloscope "Prism Oscilloscope" Posc "PRISM_SCOPE_OSCILLOSCOPE=1")
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include "ScopeEngine.h"
#include "oscilloscope.h" // reused, unmodified, from native/src
#include <vector>
#include <algorithm>
#include <cmath>
class OscilloscopeEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "oscilloscope"; }
juce::Identifier frameEventId() const override { return frameId; }
void setSampleRate(double sampleRate) override
{
osc.setSampleRate((float) sampleRate);
osc.setDisplaySamples(normalizedDisplaySamples(sampleRate));
}
void configure(const juce::var& settings) override
{
pitchLock = (bool) settings.getProperty("pitchLock", true);
osc.setPitchLock(pitchLock);
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
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]);
osc.pushSamples(mono.data(), (size_t) numSamples);
samplesSeen += numSamples;
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("pitch", 0.0);
// Pitch lock needs samples buffered before trigger detection is meaningful.
if (pitchLock && samplesSeen < kWarmupSamples)
{
obj->setProperty("samples", juce::String());
return juce::var(obj);
}
const auto result = osc.process();
const int samplesToShow = result.samplesToShow;
if (samplesToShow <= 1)
{
obj->setProperty("samples", juce::String());
return juce::var(obj);
}
float triggerIndex = result.triggerIndex;
if (! pitchLock)
{
// Free-run: show the most recent window ending at the write head.
const size_t writePos = osc.getWritePos();
triggerIndex = (float) ((writePos + Visualizer::OSCILLOSCOPE_BUFFER_SIZE - (size_t) samplesToShow)
% Visualizer::OSCILLOSCOPE_BUFFER_SIZE);
}
if ((int) window.size() != samplesToShow)
window.resize((size_t) samplesToShow);
osc.getSamplesInterpolated(window.data(), triggerIndex, (size_t) samplesToShow);
obj->setProperty("pitch", result.detectedPitch);
obj->setProperty("samples", juce::Base64::toBase64(window.data(), window.size() * sizeof(float)));
return juce::var(obj);
}
private:
// Mirrors getNormalizedOscilloscopeDisplaySamples in the renderer.
static int normalizedDisplaySamples(double sampleRate)
{
const double base = 2048.0, rateMin = 44100.0, rateMax = 48000.0;
double samples = base;
if (sampleRate > 0.0)
{
if (sampleRate < rateMin) samples = std::round(base * (sampleRate / rateMin));
else if (sampleRate > rateMax) samples = std::round(base * (sampleRate / rateMax));
}
return (int) std::clamp(samples, 64.0, 32767.0);
}
static constexpr long long kWarmupSamples = 4096;
const juce::Identifier frameId { "oscilloscopeFrame" };
Visualizer::Oscilloscope osc;
std::vector<float> mono, window;
bool pitchLock = true;
long long samplesSeen = 0;
};
+29 -43
View File
@@ -1,4 +1,6 @@
#include "PluginEditor.h"
#include "SpectrumEngine.h"
#include "OscilloscopeEngine.h"
#include <cstring>
#if ! PRISM_USE_DEV_SERVER
@@ -11,15 +13,21 @@
namespace
{
const juce::Identifier kSpectrumFrameEvent { "spectrumFrame" };
const juce::Identifier kRestoreSettingsEvent { "prismRestoreSettings" };
constexpr int kDrainCapacity = 16384;
std::unique_ptr<ScopeEngine> makeEngine()
{
#if defined(PRISM_SCOPE_OSCILLOSCOPE) && PRISM_SCOPE_OSCILLOSCOPE
return std::make_unique<OscilloscopeEngine>();
#else
return std::make_unique<SpectrumEngine>();
#endif
}
#if PRISM_USE_DEV_SERVER
// Hot-reload path: load the Vite dev server (run `npm run plugin-ui:dev`).
const juce::String kDevServerUrl { "http://localhost:5174" };
#else
// Self-contained path: serve the bundle embedded via juce_add_binary_data.
juce::String mimeForExtension(const juce::String& name)
{
if (name.endsWithIgnoreCase(".html")) return "text/html";
@@ -54,10 +62,11 @@ namespace
}
#endif
juce::WebBrowserComponent::Options makeWebOptions(PrismSpectrumEditor& editor)
juce::WebBrowserComponent::Options makeWebOptions(PrismSpectrumEditor& editor, const char* scopeId)
{
auto options = juce::WebBrowserComponent::Options{}
.withNativeIntegrationEnabled()
.withInitialisationData("prismScope", juce::String(scopeId))
.withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); })
.withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); });
#if ! PRISM_USE_DEV_SERVER
@@ -65,19 +74,13 @@ namespace
#endif
return options;
}
juce::String floatBufferToBase64(const std::vector<float>& data)
{
if (data.empty())
return {};
return juce::Base64::toBase64(data.data(), data.size() * sizeof(float));
}
}
PrismSpectrumEditor::PrismSpectrumEditor(PrismSpectrumProcessor& p)
: juce::AudioProcessorEditor(&p),
processorRef(p),
webView(makeWebOptions(*this))
engine(makeEngine()),
webView(makeWebOptions(*this, engine->scopeId()))
{
drainLeft.assign((size_t) kDrainCapacity, 0.0f);
drainRight.assign((size_t) kDrainCapacity, 0.0f);
@@ -105,18 +108,15 @@ void PrismSpectrumEditor::resized()
void PrismSpectrumEditor::onPrismConfig(juce::var payload)
{
const auto settings = payload.getProperty("settings", juce::var());
// Persist only genuine per-instance overrides (persist=true). App-default /
// restore-driven updates carry persist=false so a non-overridden instance
// keeps re-reading the app's current settings on reopen.
if ((bool) payload.getProperty("persist", false))
processorRef.setSettingsJson(payload.getProperty("json", juce::var(juce::String())).toString());
processorRef.setSettingsJson(juce::JSON::toString(settings));
// Apply the DSP-relevant settings to the (message-thread-owned) analyzer.
const int fftSize = (int) payload.getProperty("fftSize", 2048);
if (fftSize > 0 && (size_t) fftSize != spectrum.getFFTSize())
spectrum.setFFTSize((size_t) fftSize);
spectrum.setSmoothing((float) (double) payload.getProperty("smoothing", 0.9));
engine->configure(settings);
}
void PrismSpectrumEditor::onPrismReady()
@@ -125,6 +125,13 @@ void PrismSpectrumEditor::onPrismReady()
sendAppDefaults();
}
void PrismSpectrumEditor::pushRestoreSettings()
{
auto* obj = new juce::DynamicObject();
obj->setProperty("json", processorRef.getSettingsJson());
webView.emitEventIfBrowserIsVisible(kRestoreSettingsEvent, juce::var(obj));
}
void PrismSpectrumEditor::sendAppDefaults()
{
// macOS userApplicationDataDirectory is ~/Library, so append "Application Support".
@@ -137,7 +144,6 @@ void PrismSpectrumEditor::sendAppDefaults()
juce::String themeId, themeFile, profileJson;
// Active theme id -> its .iro file (the app loads the file, so it matches exactly).
if (const auto themeState = juce::JSON::parse(appData.getChildFile("theme-state.json"));
auto* obj = themeState.getDynamicObject())
themeId = obj->getProperty("activeThemeId").toString();
@@ -149,7 +155,6 @@ void PrismSpectrumEditor::sendAppDefaults()
themeFile = file.loadFileAsString();
}
// Active profile id -> the .prsm whose inner "id" matches (filenames are by name).
juce::String activeProfileId;
if (const auto profileState = juce::JSON::parse(appData.getChildFile("profile-state.json"));
auto* obj = profileState.getDynamicObject())
@@ -178,13 +183,6 @@ void PrismSpectrumEditor::sendAppDefaults()
webView.emitEventIfBrowserIsVisible(juce::Identifier("prismAppDefaults"), juce::var(payload));
}
void PrismSpectrumEditor::pushRestoreSettings()
{
auto* obj = new juce::DynamicObject();
obj->setProperty("json", processorRef.getSettingsJson());
webView.emitEventIfBrowserIsVisible(kRestoreSettingsEvent, juce::var(obj));
}
void PrismSpectrumEditor::renderFrame()
{
#if JUCE_MAC
@@ -199,24 +197,12 @@ void PrismSpectrumEditor::renderFrame()
const double sampleRate = processorRef.getSampleRateHz();
if (sampleRate > 0.0 && sampleRate != lastSampleRate)
{
spectrum.setSampleRate((float) sampleRate);
engine->setSampleRate(sampleRate);
lastSampleRate = sampleRate;
}
const int drained = processorRef.drainStereo(drainLeft.data(), drainRight.data(), (int) drainLeft.size());
if (drained > 0)
spectrum.pushStereoSamples(drainLeft.data(), drainRight.data(), (size_t) drained);
else
spectrum.pushStereoSamples(nullptr, nullptr, 0); // recompute so smoothing keeps decaying
engine->process(drainLeft.data(), drainRight.data(), drained);
const auto& mid = spectrum.getMagnitudes();
if (mid.empty())
return;
auto* payload = new juce::DynamicObject();
payload->setProperty("sampleRate", sampleRate);
payload->setProperty("magnitudes", floatBufferToBase64(mid));
payload->setProperty("side", floatBufferToBase64(spectrum.getSideMagnitudes()));
webView.emitEventIfBrowserIsVisible(kSpectrumFrameEvent, juce::var(payload));
webView.emitEventIfBrowserIsVisible(engine->frameEventId(), engine->buildFrame(sampleRate));
}
+3 -2
View File
@@ -2,7 +2,8 @@
#include <juce_gui_extra/juce_gui_extra.h>
#include "PluginProcessor.h"
#include "spectrum.h" // reused, unmodified, from native/src
#include "ScopeEngine.h"
#include <memory>
#include <vector>
/**
@@ -39,7 +40,7 @@ private:
PrismSpectrumProcessor& processorRef;
Visualizer::Spectrum spectrum { 2048 };
std::unique_ptr<ScopeEngine> engine;
std::vector<float> drainLeft, drainRight;
double lastSampleRate = 0.0;
+1 -1
View File
@@ -29,7 +29,7 @@ public:
juce::AudioProcessorEditor* createEditor() override;
bool hasEditor() const override { return true; }
const juce::String getName() const override { return "Prism Spectrum"; }
const juce::String getName() const override { return JucePlugin_Name; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
bool isMidiEffect() const override { return false; }
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <juce_core/juce_core.h>
/**
* Per-scope DSP + frame producer. The editor is otherwise scope-agnostic: it
* buffers stereo audio and, each frame, feeds it to the engine and emits the
* engine's frame payload to the webview. One implementation per scope; the build
* (PRISM_SCOPE_*) selects which one a given plugin product uses.
*/
class ScopeEngine
{
public:
virtual ~ScopeEngine() = default;
/** Stable id sent to the webview (via initialisation data) to pick the UI scope. */
virtual const char* scopeId() const = 0;
/** Event name this engine emits frames on (the webview subscribes to it). */
virtual juce::Identifier frameEventId() const = 0;
virtual void setSampleRate(double sampleRate) = 0;
/** Apply scope settings (the JS settings object) to the DSP. */
virtual void configure(const juce::var& settings) = 0;
/** Feed audio (called off the realtime thread). numSamples may be 0. */
virtual void process(const float* left, const float* right, int numSamples) = 0;
/** Build the per-frame payload to emit to the webview. */
virtual juce::var buildFrame(double sampleRate) = 0;
};
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include "ScopeEngine.h"
#include "spectrum.h" // reused, unmodified, from native/src
#include <vector>
class SpectrumEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "spectrum"; }
juce::Identifier frameEventId() const override { return frameId; }
void setSampleRate(double sampleRate) override
{
spectrum.setSampleRate((float) sampleRate);
}
void configure(const juce::var& settings) override
{
const int fftSize = (int) settings.getProperty("fftSize", 2048);
if (fftSize > 0 && (size_t) fftSize != spectrum.getFFTSize())
spectrum.setFFTSize((size_t) fftSize);
spectrum.setSmoothing((float) (double) settings.getProperty("smoothing", 0.9));
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples > 0)
spectrum.pushStereoSamples(left, right, (size_t) numSamples);
else
spectrum.pushStereoSamples(nullptr, nullptr, 0); // recompute / decay
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("magnitudes", toBase64(spectrum.getMagnitudes()));
obj->setProperty("side", toBase64(spectrum.getSideMagnitudes()));
return juce::var(obj);
}
private:
static juce::String toBase64(const std::vector<float>& data)
{
return data.empty() ? juce::String()
: juce::Base64::toBase64(data.data(), data.size() * sizeof(float));
}
const juce::Identifier frameId { "spectrumFrame" };
Visualizer::Spectrum spectrum { 2048 };
};
@@ -0,0 +1,52 @@
import type { OscilloscopeNativeAnalyzer, OscilloscopeResult } from '../renderer/audio/native'
/**
* Drop-in `OscilloscopeNativeAnalyzer` for the plugin webview.
*
* The oscilloscope DSP (circular buffer + trigger detection) runs in the C++
* plugin, which pushes the finished, already-triggered display window each frame.
* This shim serves that window through the interface `Oscilloscope` consumes, so
* the visualizer renders it unchanged. `pushSamples` is a no-op (audio never
* flows through the webview); `processContinuous` reports the window at index 0.
*/
export class BridgeOscilloscopeAnalyzer implements OscilloscopeNativeAnalyzer {
private samples = new Float32Array(0)
private pitch = 0
/** Called by the bridge whenever the host emits a new oscilloscope frame. */
setSamples(samples: Float32Array, pitch: number): void {
if (samples.length !== this.samples.length) {
this.samples = new Float32Array(samples.length)
}
this.samples.set(samples)
this.pitch = pitch
}
isAvailable(): boolean {
return true
}
setSampleRate(_sampleRate: number): void {}
setPitchLock(_enabled: boolean): void {}
setDisplaySamples(_samples: number): void {}
pushSamples(_samples: Float32Array): void {}
processContinuous(): OscilloscopeResult {
const count = this.samples.length
// C++ already applied the trigger, so the window starts at index 0.
return { triggerIndex: 0, samplesToShow: count, detectedPitch: this.pitch, writePos: count }
}
fillSamples(_startPos: number, output: Float32Array): number {
const count = Math.min(output.length, this.samples.length)
if (count > 0) {
output.set(this.samples.subarray(0, count), 0)
}
return count
}
reset(): void {
this.samples = new Float32Array(0)
this.pitch = 0
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { JSX } from 'react'
// Prism's settings icon (matches the app's scope chrome).
export default function GearIcon(): JSX.Element {
return (
<svg viewBox="0 0 118 118" width="15" height="15" aria-hidden="true">
<path d="M104.811 35.1118L102.384 30.9002C100.549 27.7151 99.6313 26.1225 98.0697 25.4874C96.5082 24.8524 94.7421 25.3535 91.2105 26.3557L85.2112 28.0456C82.9564 28.5655 80.5905 28.2706 78.5319 27.2127L76.8755 26.2571C75.1099 25.1263 73.7519 23.4591 73.0002 21.4993L71.3585 16.5955C70.2788 13.3504 69.7389 11.7279 68.4537 10.7998C67.169 9.87175 65.4619 9.87175 62.0478 9.87175H56.5667C53.1531 9.87175 51.446 9.87175 50.1608 10.7998C48.8758 11.7279 48.336 13.3504 47.2564 16.5955L45.6145 21.4993C44.8628 23.4591 43.5048 25.1263 41.7394 26.2571L40.083 27.2127C38.0242 28.2706 35.6585 28.5655 33.4037 28.0456L27.4042 26.3557C23.8724 25.3535 22.1065 24.8524 20.5451 25.4874C18.9836 26.1225 18.066 27.7151 16.2306 30.9002L13.8038 35.1118C12.0834 38.0975 11.2232 39.5903 11.3902 41.1795C11.5571 42.7687 12.7087 44.0493 15.0118 46.6106L20.0811 52.2779C21.3201 53.8464 22.1997 56.58 22.1997 59.0379C22.1997 61.4967 21.3204 64.2294 20.0812 65.7983L15.0118 71.4657C12.7087 74.0273 11.5572 75.3076 11.3902 76.8972C11.2232 78.4862 12.0834 79.9789 13.8038 82.9643L16.2306 87.1759C18.0659 90.361 18.9836 91.954 20.5451 92.5887C22.1065 93.2239 23.8724 92.7229 27.4043 91.7204L33.4035 90.0306C35.6587 89.5104 38.0248 89.8059 40.0839 90.8639L41.74 91.8197C43.5051 92.9506 44.8628 94.6173 45.6143 96.5771L47.2564 101.481C48.336 104.726 48.8758 106.349 50.1608 107.277C51.446 108.205 53.1531 108.205 56.5667 108.205H62.0478C65.4619 108.205 67.169 108.205 68.4537 107.277C69.7389 106.349 70.2788 104.726 71.3585 101.481L73.0007 96.5771C73.7519 94.6173 75.1094 92.9506 76.875 91.8197L78.5309 90.8639C80.59 89.8059 82.9559 89.5104 85.2112 90.0306L91.2105 91.7204C94.7421 92.7229 96.5082 93.2239 98.0697 92.5887C99.6313 91.954 100.549 90.361 102.384 87.1759L104.811 82.9643C106.531 79.9789 107.391 78.4862 107.225 76.8972C107.057 75.3076 105.906 74.0273 103.603 71.4657L98.5334 65.7983C97.2944 64.2294 96.4148 61.4967 96.4148 59.0379C96.4148 56.58 97.2949 53.8464 98.5334 52.2779L103.603 46.6106C105.906 44.0493 107.057 42.7687 107.225 41.1795C107.391 39.5903 106.531 38.0975 104.811 35.1118Z" fill="none" stroke="currentColor" strokeWidth="8.5" strokeLinecap="round" />
<path d="M76.3042 59C76.3042 68.5039 68.5998 76.2083 59.0959 76.2083C49.592 76.2083 41.8877 68.5039 41.8877 59C41.8877 49.4961 49.592 41.7917 59.0959 41.7917C68.5998 41.7917 76.3042 49.4961 76.3042 59Z" fill="none" stroke="currentColor" strokeWidth="8.5" />
</svg>
)
}
+73
View File
@@ -0,0 +1,73 @@
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'
interface OscilloscopeScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeOscilloscopeAnalyzer
settings: ScopeSettings['oscilloscope']
theme: ResolvedOscilloscopeTheme
}
export default function OscilloscopeScope({
dataSource,
nativeAnalyzer,
settings,
theme,
}: OscilloscopeScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Oscilloscope | null>(null)
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const viz = new Oscilloscope(canvas, {
...oscilloscopeSettingsToOptions(settings, theme),
dataSource,
nativeAnalyzer,
})
vizRef.current = viz
const applySize = (): void => {
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()
}
}
applySize()
viz.start()
const observer = new ResizeObserver(applySize)
observer.observe(container)
return () => {
observer.disconnect()
viz.dispose()
vizRef.current = null
}
// settings/theme applied via setOptions below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSource, nativeAnalyzer])
useEffect(() => {
vizRef.current?.setOptions(oscilloscopeSettingsToOptions(settings, theme))
}, [settings, theme])
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+6
View File
@@ -46,6 +46,12 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource {
return this.sessionState.capturing ? [this.sentinelStereo] : []
}
// Oscilloscope: same sentinel trick — the DSP runs in C++ and pushes finished
// display windows; this just advances the visualizer's warmup/"new data" gate.
getPendingOscilloscopeSamples(): Float32Array[] {
return this.sessionState.capturing ? [this.sentinel] : []
}
getSampleRate(): number {
return this.sessionState.sampleRate
}
+48
View File
@@ -0,0 +1,48 @@
import { useState, type JSX, type ReactNode } from 'react'
import type { ScopeKind } from '../types/scope'
import type { ScopeSettings } from '../types/settings'
import type { PrismResolvedTheme } from '../types/theme'
import ScopeSettingsSection from '../renderer/components/ScopeSettingsSection'
import GearIcon from './GearIcon'
import { useScopeHostSync } from './useScopeHostSync'
interface ScopeAppProps<K extends ScopeKind> {
kind: K
/** Render the scope's canvas given the current settings + resolved theme. */
renderScope: (settings: ScopeSettings[K], theme: PrismResolvedTheme) => ReactNode
}
/**
* Generic plugin shell for any scope: hosts the canvas + a gear-toggled settings
* drawer (the reused ScopeSettingsSection), wired to host sync via useScopeHostSync.
*/
export default function ScopeApp<K extends ScopeKind>({ kind, renderScope }: ScopeAppProps<K>): JSX.Element {
const { settings, resolvedTheme, handleUpdate } = useScopeHostSync(kind)
const [settingsOpen, setSettingsOpen] = useState(false)
return (
<div className="spectrum-app">
{renderScope(settings, resolvedTheme)}
<button
type="button"
className={`spectrum-app__gear ${settingsOpen ? 'is-active' : ''}`.trim()}
onClick={() => setSettingsOpen((open) => !open)}
aria-label="Settings"
title="Settings"
>
<GearIcon />
</button>
{settingsOpen && (
<div className="spectrum-app__settings">
<ScopeSettingsSection
kind={kind}
settings={settings}
onUpdate={(_k, partial) => handleUpdate(partial as unknown as Partial<ScopeSettings[K]>)}
/>
</div>
)}
</div>
)
}
-145
View File
@@ -1,145 +0,0 @@
import { useCallback, useEffect, useRef, useState, type JSX } from 'react'
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
import type { ScopeKind } from '../types/scope'
import type { ResolvedSpectrumTheme } from '../types/theme'
import { createBundledThemes, createDefaultTheme, parseThemeFileContent, resolveTheme } from '../shared/themeState'
import ScopeSettingsSection from '../renderer/components/ScopeSettingsSection'
import SpectrumScope from './SpectrumScope'
import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { emitToHost, onHostEvent } from './juceBridge'
interface SpectrumAppProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeSpectrumAnalyzer
}
const DEFAULTS = DEFAULT_SCOPE_SETTINGS.spectrum
const DEFAULT_SPECTRUM_THEME = resolveTheme(createDefaultTheme()).spectrum
function mergeSpectrumSettings(raw: unknown): ScopeSettings['spectrum'] {
if (typeof raw !== 'object' || raw === null) return { ...DEFAULTS }
const parsed = raw as Record<string, unknown>
const next = { ...DEFAULTS }
for (const key of Object.keys(DEFAULTS) as (keyof ScopeSettings['spectrum'])[]) {
if (key in parsed && typeof parsed[key] === typeof DEFAULTS[key]) {
;(next as Record<string, unknown>)[key] = parsed[key]
}
}
return next
}
// Resolve the app's active theme: prefer its on-disk .iro (matches the app
// exactly), fall back to the bundled theme by name, then the default.
function resolveAppSpectrumTheme(themeId: string, themeFile: string): ResolvedSpectrumTheme {
try {
if (themeFile) return resolveTheme(parseThemeFileContent(themeFile, themeId || undefined)).spectrum
} catch {
// fall through
}
if (themeId) {
const bundled = createBundledThemes().find((theme) => theme.name === themeId)
if (bundled) return resolveTheme(bundled).spectrum
}
return DEFAULT_SPECTRUM_THEME
}
function resolveAppSpectrumSettings(profileJson: string): ScopeSettings['spectrum'] {
try {
const parsed = JSON.parse(profileJson) as { scopeSettings?: { spectrum?: unknown } }
if (parsed?.scopeSettings?.spectrum) return mergeSpectrumSettings(parsed.scopeSettings.spectrum)
} catch {
// fall through
}
return { ...DEFAULTS }
}
// Prism's settings icon (matches the app's scope chrome).
function GearIcon(): JSX.Element {
return (
<svg viewBox="0 0 118 118" width="15" height="15" aria-hidden="true">
<path d="M104.811 35.1118L102.384 30.9002C100.549 27.7151 99.6313 26.1225 98.0697 25.4874C96.5082 24.8524 94.7421 25.3535 91.2105 26.3557L85.2112 28.0456C82.9564 28.5655 80.5905 28.2706 78.5319 27.2127L76.8755 26.2571C75.1099 25.1263 73.7519 23.4591 73.0002 21.4993L71.3585 16.5955C70.2788 13.3504 69.7389 11.7279 68.4537 10.7998C67.169 9.87175 65.4619 9.87175 62.0478 9.87175H56.5667C53.1531 9.87175 51.446 9.87175 50.1608 10.7998C48.8758 11.7279 48.336 13.3504 47.2564 16.5955L45.6145 21.4993C44.8628 23.4591 43.5048 25.1263 41.7394 26.2571L40.083 27.2127C38.0242 28.2706 35.6585 28.5655 33.4037 28.0456L27.4042 26.3557C23.8724 25.3535 22.1065 24.8524 20.5451 25.4874C18.9836 26.1225 18.066 27.7151 16.2306 30.9002L13.8038 35.1118C12.0834 38.0975 11.2232 39.5903 11.3902 41.1795C11.5571 42.7687 12.7087 44.0493 15.0118 46.6106L20.0811 52.2779C21.3201 53.8464 22.1997 56.58 22.1997 59.0379C22.1997 61.4967 21.3204 64.2294 20.0812 65.7983L15.0118 71.4657C12.7087 74.0273 11.5572 75.3076 11.3902 76.8972C11.2232 78.4862 12.0834 79.9789 13.8038 82.9643L16.2306 87.1759C18.0659 90.361 18.9836 91.954 20.5451 92.5887C22.1065 93.2239 23.8724 92.7229 27.4043 91.7204L33.4035 90.0306C35.6587 89.5104 38.0248 89.8059 40.0839 90.8639L41.74 91.8197C43.5051 92.9506 44.8628 94.6173 45.6143 96.5771L47.2564 101.481C48.336 104.726 48.8758 106.349 50.1608 107.277C51.446 108.205 53.1531 108.205 56.5667 108.205H62.0478C65.4619 108.205 67.169 108.205 68.4537 107.277C69.7389 106.349 70.2788 104.726 71.3585 101.481L73.0007 96.5771C73.7519 94.6173 75.1094 92.9506 76.875 91.8197L78.5309 90.8639C80.59 89.8059 82.9559 89.5104 85.2112 90.0306L91.2105 91.7204C94.7421 92.7229 96.5082 93.2239 98.0697 92.5887C99.6313 91.954 100.549 90.361 102.384 87.1759L104.811 82.9643C106.531 79.9789 107.391 78.4862 107.225 76.8972C107.057 75.3076 105.906 74.0273 103.603 71.4657L98.5334 65.7983C97.2944 64.2294 96.4148 61.4967 96.4148 59.0379C96.4148 56.58 97.2949 53.8464 98.5334 52.2779L103.603 46.6106C105.906 44.0493 107.057 42.7687 107.225 41.1795C107.391 39.5903 106.531 38.0975 104.811 35.1118Z" fill="none" stroke="currentColor" strokeWidth="8.5" strokeLinecap="round" />
<path d="M76.3042 59C76.3042 68.5039 68.5998 76.2083 59.0959 76.2083C49.592 76.2083 41.8877 68.5039 41.8877 59C41.8877 49.4961 49.592 41.7917 59.0959 41.7917C68.5998 41.7917 76.3042 49.4961 76.3042 59Z" fill="none" stroke="currentColor" strokeWidth="8.5" />
</svg>
)
}
export default function SpectrumApp({ dataSource, nativeAnalyzer }: SpectrumAppProps): JSX.Element {
const [settings, setSettings] = useState<ScopeSettings['spectrum']>({ ...DEFAULTS })
const [spectrumTheme, setSpectrumTheme] = useState<ResolvedSpectrumTheme>(DEFAULT_SPECTRUM_THEME)
const [settingsOpen, setSettingsOpen] = useState(false)
const settingsRef = useRef(settings)
// True once this instance has a per-instance override (user edit or restored
// DAW state); app defaults must not clobber it.
const hasOverride = useRef(false)
// Set settings, apply DSP params on the host, and optionally persist (only for
// genuine per-instance overrides — see C++ onPrismConfig).
const applySettings = useCallback((next: ScopeSettings['spectrum'], persist: boolean): void => {
settingsRef.current = next
setSettings(next)
emitToHost('prismConfig', {
json: persist ? JSON.stringify(next) : '',
fftSize: next.fftSize,
smoothing: next.smoothing,
persist,
})
}, [])
useEffect(() => {
const unsubRestore = onHostEvent('prismRestoreSettings', (payload) => {
const json = (payload as { json?: unknown })?.json
if (typeof json === 'string' && json.length > 0) {
hasOverride.current = true
applySettings(mergeSpectrumSettings(JSON.parse(json)), false)
}
})
const unsubDefaults = onHostEvent('prismAppDefaults', (payload) => {
const p = (payload ?? {}) as { themeId?: string; themeFile?: string; profileJson?: string }
setSpectrumTheme(resolveAppSpectrumTheme(p.themeId ?? '', p.themeFile ?? ''))
if (!hasOverride.current) {
applySettings(resolveAppSpectrumSettings(p.profileJson ?? ''), false)
}
})
emitToHost('prismReady', {})
return () => {
unsubRestore()
unsubDefaults()
}
}, [applySettings])
const handleUpdate = useCallback(<K extends ScopeKind>(_kind: K, partial: Partial<ScopeSettings[K]>): void => {
hasOverride.current = true
applySettings({ ...settingsRef.current, ...(partial as Partial<ScopeSettings['spectrum']>) }, true)
}, [applySettings])
return (
<div className="spectrum-app">
<SpectrumScope
dataSource={dataSource}
nativeAnalyzer={nativeAnalyzer}
settings={settings}
theme={spectrumTheme}
/>
<button
type="button"
className={`spectrum-app__gear ${settingsOpen ? 'is-active' : ''}`.trim()}
onClick={() => setSettingsOpen((open) => !open)}
aria-label="Settings"
title="Settings"
>
<GearIcon />
</button>
{settingsOpen && (
<div className="spectrum-app__settings">
<ScopeSettingsSection kind="spectrum" settings={settings} onUpdate={handleUpdate} />
</div>
)}
</div>
)
}
+78 -1
View File
@@ -89,7 +89,7 @@ export function onHostEvent(eventId: string, handler: (payload: unknown) => void
}
}
function base64ToFloat32Array(b64: string): Float32Array {
export function base64ToFloat32Array(b64: string): Float32Array {
if (!b64) return new Float32Array(0)
const binary = atob(b64)
const byteLength = binary.length
@@ -165,3 +165,80 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
}
}
// ---------------------------------------------------------------------------
// Oscilloscope frames (event "oscilloscopeFrame": { sampleRate, samples, pitch }).
export interface OscilloscopeFrame {
sampleRate: number
/** Already-triggered display window of time-domain samples. */
samples: Float32Array
detectedPitch: number
}
interface OscilloscopeFramePayload {
sampleRate?: number
samples?: string
pitch?: number
}
function decodeOscilloscopeFrame(payload: unknown): OscilloscopeFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const { sampleRate, samples, pitch } = payload as OscilloscopeFramePayload
if (typeof samples !== 'string' || samples.length === 0) return null
return {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
samples: base64ToFloat32Array(samples),
detectedPitch: typeof pitch === 'number' ? pitch : 0,
}
}
export interface OscilloscopeBridgeHandlers {
onFrame: (frame: OscilloscopeFrame) => void
onConnected?: (usingMock: boolean) => void
}
export function connectOscilloscopeBridge(handlers: OscilloscopeBridgeHandlers): () => void {
let disposed = false
let listenerId: number | null = null
let mockRaf: number | null = null
const startMock = (): void => {
handlers.onConnected?.(true)
console.warn('[prism-plugin] no JUCE host — using synthetic oscilloscope (browser dev mode)')
const count = 2048
const samples = new Float32Array(count)
let phase = 0
const tick = (): void => {
if (disposed) return
phase += 0.08
for (let i = 0; i < count; i += 1) {
const t = (i / count) * Math.PI * 2 * 3
samples[i] = Math.sin(t + phase) * 0.7 + Math.sin(t * 2 + phase) * 0.15
}
handlers.onFrame({ sampleRate: 48000, samples, detectedPitch: 220 })
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
}
void ensureBackend().then((backend) => {
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('oscilloscopeFrame', (payload) => {
const frame = decodeOscilloscopeFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (oscilloscope)')
} else {
startMock()
}
})
return () => {
disposed = true
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
}
}
+64 -19
View File
@@ -1,33 +1,78 @@
import { StrictMode } from 'react'
import { StrictMode, type JSX } from 'react'
import { createRoot } from 'react-dom/client'
import '../renderer/styles/globals.css'
import './styles.css'
import SpectrumApp from './SpectrumApp'
import ScopeApp from './ScopeApp'
import SpectrumScope from './SpectrumScope'
import OscilloscopeScope from './OscilloscopeScope'
import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
import { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { connectSpectrumBridge } from './juceBridge'
import { connectOscilloscopeBridge, connectSpectrumBridge } from './juceBridge'
// The C++ plugin tells us which scope it is via JUCE initialisation data.
// JUCE stores each value as an array (e.g. prismScope = ["oscilloscope"]).
function getScopeKind(): string {
const raw = (window as unknown as {
__JUCE__?: { initialisationData?: { prismScope?: unknown } }
}).__JUCE__?.initialisationData?.prismScope
const value = Array.isArray(raw) ? raw[0] : raw
return typeof value === 'string' ? value : 'spectrum'
}
// One shared analyzer shim + data source for the lifetime of the page.
const nativeAnalyzer = new BridgeSpectrumAnalyzer(2048)
const dataSource = new PluginWebViewDataSource()
// Pipe host frames into the shim/data source. The SpectrumAnalyzer (mounted by
// <SpectrumApp/>) reads from both on its own render loop.
connectSpectrumBridge({
onFrame: (frame) => {
nativeAnalyzer.setMagnitudes(frame.magnitudes, frame.side)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
function buildApp(): JSX.Element {
if (getScopeKind() === 'oscilloscope') {
const analyzer = new BridgeOscilloscopeAnalyzer()
connectOscilloscopeBridge({
onFrame: (frame) => {
analyzer.setSamples(frame.samples, frame.detectedPitch)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
return (
<ScopeApp
kind="oscilloscope"
renderScope={(settings, theme) => (
<OscilloscopeScope
settings={settings}
theme={theme.oscilloscope}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
const analyzer = new BridgeSpectrumAnalyzer(2048)
connectSpectrumBridge({
onFrame: (frame) => {
analyzer.setMagnitudes(frame.magnitudes, frame.side)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
return (
<ScopeApp
kind="spectrum"
renderScope={(settings, theme) => (
<SpectrumScope
settings={settings}
theme={theme.spectrum}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Missing #root element')
}
createRoot(rootElement).render(
<StrictMode>
<SpectrumApp dataSource={dataSource} nativeAnalyzer={nativeAnalyzer} />
</StrictMode>,
)
createRoot(rootElement).render(<StrictMode>{buildApp()}</StrictMode>)
+24
View File
@@ -0,0 +1,24 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedOscilloscopeTheme } from '../types/theme'
import type { OscilloscopeOptions } from '../renderer/visualizers/Oscilloscope'
/**
* Map Prism's oscilloscope settings + resolved theme to Oscilloscope options.
* Mirrors the `oscilloscope` case of `scopeSettingsToOptions` in ScopeModule.tsx.
*/
export function oscilloscopeSettingsToOptions(
settings: ScopeSettings['oscilloscope'],
theme: ResolvedOscilloscopeTheme,
): OscilloscopeOptions {
return {
lineColor: theme.line,
backgroundColor: theme.background,
gridMajorColor: theme.guides,
gridMinorColor: theme.guidesSecondary,
underfillColor: theme.fill,
pitchLock: settings.pitchLock,
underfillEnabled: settings.underfillEnabled,
showGrid: settings.showGrid,
lineWidth: settings.lineWidth,
}
}
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from 'react'
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 { emitToHost, onHostEvent } from './juceBridge'
const DEFAULT_THEME = resolveTheme(createDefaultTheme())
function mergeScopeSettings<K extends ScopeKind>(kind: K, raw: unknown): ScopeSettings[K] {
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 {
try {
if (themeFile) return resolveTheme(parseThemeFileContent(themeFile, themeId || undefined))
} catch {
// fall through
}
if (themeId) {
const bundled = createBundledThemes().find((theme) => theme.name === themeId)
if (bundled) return resolveTheme(bundled)
}
return DEFAULT_THEME
}
function resolveAppScopeSettings<K extends ScopeKind>(kind: K, profileJson: string): ScopeSettings[K] {
try {
const parsed = JSON.parse(profileJson) as { scopeSettings?: Record<string, unknown> }
const scoped = parsed?.scopeSettings?.[kind]
if (scoped) return mergeScopeSettings(kind, scoped)
} catch {
// fall through
}
return { ...(DEFAULT_SCOPE_SETTINGS[kind] as object) } as ScopeSettings[K]
}
export interface ScopeHostSync<K extends ScopeKind> {
settings: ScopeSettings[K]
resolvedTheme: PrismResolvedTheme
handleUpdate: (partial: Partial<ScopeSettings[K]>) => void
}
/**
* Shared host sync for any scope plugin: applies app-default theme/settings,
* persists per-instance overrides, and reconciles precedence (per-instance DAW
* override > app settings > built-in defaults). Theme always follows the app.
*/
export function useScopeHostSync<K extends ScopeKind>(kind: K): ScopeHostSync<K> {
const [settings, setSettings] = useState<ScopeSettings[K]>(() => mergeScopeSettings(kind, undefined))
const [resolvedTheme, setResolvedTheme] = useState<PrismResolvedTheme>(DEFAULT_THEME)
const settingsRef = useRef(settings)
const hasOverride = useRef(false)
const applySettings = useCallback((next: ScopeSettings[K], persist: boolean): void => {
settingsRef.current = next
setSettings(next)
emitToHost('prismConfig', { settings: next, persist })
}, [])
useEffect(() => {
const unsubRestore = onHostEvent('prismRestoreSettings', (payload) => {
const json = (payload as { json?: unknown })?.json
if (typeof json === 'string' && json.length > 0) {
try {
hasOverride.current = true
applySettings(mergeScopeSettings(kind, JSON.parse(json)), false)
} catch {
// ignore malformed saved settings
}
}
})
const unsubDefaults = onHostEvent('prismAppDefaults', (payload) => {
const p = (payload ?? {}) as { themeId?: string; themeFile?: string; profileJson?: string }
setResolvedTheme(resolveAppTheme(p.themeId ?? '', p.themeFile ?? ''))
if (!hasOverride.current) {
applySettings(resolveAppScopeSettings(kind, p.profileJson ?? ''), false)
}
})
emitToHost('prismReady', {})
return () => {
unsubRestore()
unsubDefaults()
}
}, [kind, applySettings])
const handleUpdate = useCallback((partial: Partial<ScopeSettings[K]>): void => {
hasOverride.current = true
applySettings({ ...settingsRef.current, ...partial }, true)
}, [applySettings])
return { settings, resolvedTheme, handleUpdate }
}
+17
View File
@@ -58,8 +58,25 @@ export interface SpectrumNativeAnalyzer {
isAvailable?: () => boolean
}
// Injectable interface for the oscilloscope DSP (mirrors SpectrumNativeAnalyzer)
// so the visualizer can be driven by a non-N-API source (e.g. a plugin webview).
export interface OscilloscopeNativeAnalyzer {
setSampleRate(sampleRate: number): void
setPitchLock(enabled: boolean): void
setDisplaySamples(samples: number): void
pushSamples(samples: Float32Array): void
processContinuous(): OscilloscopeResult | null
fillSamples(startPos: number, output: Float32Array): number
reset(): void
isAvailable?: () => boolean
}
// Export the native module functions with type safety
export const oscilloscope = {
isAvailable: (): boolean => {
return Boolean(nativeModule?.oscilloscope)
},
setSampleRate: (sampleRate: number): void => {
nativeModule?.oscilloscope.setSampleRate(sampleRate)
},
+29 -22
View File
@@ -1,8 +1,8 @@
import { audioRouter } from '../audio/AudioRouter'
import {
oscilloscope as nativeOscilloscope,
oscilloscope as defaultNativeOscilloscope,
OSCILLOSCOPE_BUFFER_SIZE,
isNativeAvailable
type OscilloscopeNativeAnalyzer,
} from '../audio/native'
import { getNormalizedOscilloscopeDisplaySamples } from '../audio/native/oscilloscopeDisplaySamples'
import { colorToRgbChannels, multiplyColorAlpha } from '../utils/color'
@@ -26,9 +26,10 @@ export interface OscilloscopeOptions {
underfillEnabled?: boolean
dataSource?: OscilloscopeDataSource
frameScheduler?: FrameScheduler
nativeAnalyzer?: OscilloscopeNativeAnalyzer | null
}
type ResolvedOscilloscopeOptions = Required<Omit<OscilloscopeOptions, 'dataSource' | 'frameScheduler'>>
type ResolvedOscilloscopeOptions = Required<Omit<OscilloscopeOptions, 'dataSource' | 'frameScheduler' | 'nativeAnalyzer'>>
const defaultOptions: ResolvedOscilloscopeOptions = {
lineColor: '#00ffff',
@@ -77,6 +78,7 @@ export class Oscilloscope {
private ctx: CanvasRenderingContext2D
private options: ResolvedOscilloscopeOptions
private dataSource: OscilloscopeDataSource
private nativeAnalyzer: OscilloscopeNativeAnalyzer
private frameLoop: VisualizerFrameLoop
private nativeInitialized = false
private samplesReceived = 0
@@ -95,9 +97,10 @@ export class Oscilloscope {
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
const { dataSource, frameScheduler, ...optionOverrides } = options
const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options
this.options = { ...defaultOptions, ...optionOverrides }
this.dataSource = dataSource ?? defaultOscilloscopeDataSource
this.nativeAnalyzer = nativeAnalyzer === undefined ? defaultNativeOscilloscope : (nativeAnalyzer ?? defaultNativeOscilloscope)
this.frameLoop = new VisualizerFrameLoop({
frameScheduler,
shouldRun: () => this.dataSource.isPlaying(),
@@ -121,27 +124,31 @@ export class Oscilloscope {
})
}
private nativeReady(): boolean {
return Boolean(this.nativeAnalyzer) && this.nativeAnalyzer.isAvailable?.() !== false
}
private initNative(): void {
if (isNativeAvailable() && !this.nativeInitialized) {
if (this.nativeReady() && !this.nativeInitialized) {
const sampleRate = this.dataSource.getSampleRate()
this.lastSampleRate = 0
nativeOscilloscope.setSampleRate(sampleRate)
nativeOscilloscope.setPitchLock(this.options.pitchLock)
nativeOscilloscope.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(sampleRate))
this.nativeAnalyzer.setSampleRate(sampleRate)
this.nativeAnalyzer.setPitchLock(this.options.pitchLock)
this.nativeAnalyzer.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(sampleRate))
this.nativeInitialized = true
console.log(`Oscilloscope: Using native DSP with AudioWorklet (${sampleRate}Hz)`)
} else if (!isNativeAvailable()) {
} else if (!this.nativeReady()) {
console.error('Oscilloscope: Native DSP not available!')
}
}
private updateSampleRateIfNeeded(): void {
if (!isNativeAvailable()) return
if (!this.nativeReady()) return
const currentRate = this.dataSource.getSampleRate()
if (currentRate !== this.lastSampleRate && currentRate > 0) {
this.lastSampleRate = currentRate
nativeOscilloscope.setSampleRate(currentRate)
nativeOscilloscope.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(currentRate))
this.nativeAnalyzer.setSampleRate(currentRate)
this.nativeAnalyzer.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(currentRate))
console.log(`Oscilloscope: Sample rate updated to ${currentRate}Hz`)
}
}
@@ -155,8 +162,8 @@ export class Oscilloscope {
this.reset()
}
if (isNativeAvailable() && options.pitchLock !== undefined) {
nativeOscilloscope.setPitchLock(options.pitchLock)
if (this.nativeReady() && options.pitchLock !== undefined) {
this.nativeAnalyzer.setPitchLock(options.pitchLock)
}
this.staticLayerKey = ''
@@ -226,7 +233,7 @@ export class Oscilloscope {
this.renderStaticLayer()
if (!isNativeAvailable()) {
if (!this.nativeReady()) {
console.error('Oscilloscope: Native DSP required')
return
}
@@ -240,7 +247,7 @@ export class Oscilloscope {
const pendingSamples = this.dataSource.getPendingOscilloscopeSamples()
if (pendingSamples.length > 0) {
const merged = this.concatMonoChunks(pendingSamples)
nativeOscilloscope.pushSamples(merged)
this.nativeAnalyzer.pushSamples(merged)
this.samplesReceived += merged.length
}
@@ -248,7 +255,7 @@ export class Oscilloscope {
return
}
const result = nativeOscilloscope.processContinuous()
const result = this.nativeAnalyzer.processContinuous()
if (!result) {
return
}
@@ -263,7 +270,7 @@ export class Oscilloscope {
}
const renderData = this.ensureRenderBuffer(samplesToShow)
const sampleCount = nativeOscilloscope.fillSamples(triggerIndex, renderData)
const sampleCount = this.nativeAnalyzer.fillSamples(triggerIndex, renderData)
if (sampleCount < 2) {
return
}
@@ -383,8 +390,8 @@ export class Oscilloscope {
reset(): void {
this.samplesReceived = 0
if (isNativeAvailable()) {
nativeOscilloscope.reset()
if (this.nativeReady()) {
this.nativeAnalyzer.reset()
}
this.invalidate()
@@ -399,8 +406,8 @@ export class Oscilloscope {
this.unsubscribeSessionChange = null
}
if (isNativeAvailable()) {
nativeOscilloscope.reset()
if (this.nativeReady()) {
this.nativeAnalyzer.reset()
}
this.samplesReceived = 0