mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
oscilloscope VST
This commit is contained in:
+64
-53
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user