diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index d92110e..fa84a84 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -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") diff --git a/plugin/Source/OscilloscopeEngine.h b/plugin/Source/OscilloscopeEngine.h new file mode 100644 index 0000000..eec5346 --- /dev/null +++ b/plugin/Source/OscilloscopeEngine.h @@ -0,0 +1,98 @@ +#pragma once + +#include "ScopeEngine.h" +#include "oscilloscope.h" // reused, unmodified, from native/src +#include +#include +#include + +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 mono, window; + bool pitchLock = true; + long long samplesSeen = 0; +}; diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp index 0c66fd4..e97dfb4 100644 --- a/plugin/Source/PluginEditor.cpp +++ b/plugin/Source/PluginEditor.cpp @@ -1,4 +1,6 @@ #include "PluginEditor.h" +#include "SpectrumEngine.h" +#include "OscilloscopeEngine.h" #include #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 makeEngine() + { +#if defined(PRISM_SCOPE_OSCILLOSCOPE) && PRISM_SCOPE_OSCILLOSCOPE + return std::make_unique(); +#else + return std::make_unique(); +#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& 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)); } diff --git a/plugin/Source/PluginEditor.h b/plugin/Source/PluginEditor.h index e2f7d7d..763e622 100644 --- a/plugin/Source/PluginEditor.h +++ b/plugin/Source/PluginEditor.h @@ -2,7 +2,8 @@ #include #include "PluginProcessor.h" -#include "spectrum.h" // reused, unmodified, from native/src +#include "ScopeEngine.h" +#include #include /** @@ -39,7 +40,7 @@ private: PrismSpectrumProcessor& processorRef; - Visualizer::Spectrum spectrum { 2048 }; + std::unique_ptr engine; std::vector drainLeft, drainRight; double lastSampleRate = 0.0; diff --git a/plugin/Source/PluginProcessor.h b/plugin/Source/PluginProcessor.h index 8978337..58b033a 100644 --- a/plugin/Source/PluginProcessor.h +++ b/plugin/Source/PluginProcessor.h @@ -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; } diff --git a/plugin/Source/ScopeEngine.h b/plugin/Source/ScopeEngine.h new file mode 100644 index 0000000..809fcaf --- /dev/null +++ b/plugin/Source/ScopeEngine.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +/** + * 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; +}; diff --git a/plugin/Source/SpectrumEngine.h b/plugin/Source/SpectrumEngine.h new file mode 100644 index 0000000..75c2b24 --- /dev/null +++ b/plugin/Source/SpectrumEngine.h @@ -0,0 +1,53 @@ +#pragma once + +#include "ScopeEngine.h" +#include "spectrum.h" // reused, unmodified, from native/src +#include + +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& data) + { + return data.empty() ? juce::String() + : juce::Base64::toBase64(data.data(), data.size() * sizeof(float)); + } + + const juce::Identifier frameId { "spectrumFrame" }; + Visualizer::Spectrum spectrum { 2048 }; +}; diff --git a/src/plugin-ui/BridgeOscilloscopeAnalyzer.ts b/src/plugin-ui/BridgeOscilloscopeAnalyzer.ts new file mode 100644 index 0000000..10ee42c --- /dev/null +++ b/src/plugin-ui/BridgeOscilloscopeAnalyzer.ts @@ -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 + } +} diff --git a/src/plugin-ui/GearIcon.tsx b/src/plugin-ui/GearIcon.tsx new file mode 100644 index 0000000..73b7454 --- /dev/null +++ b/src/plugin-ui/GearIcon.tsx @@ -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 ( + + ) +} diff --git a/src/plugin-ui/OscilloscopeScope.tsx b/src/plugin-ui/OscilloscopeScope.tsx new file mode 100644 index 0000000..1d67932 --- /dev/null +++ b/src/plugin-ui/OscilloscopeScope.tsx @@ -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(null) + const canvasRef = useRef(null) + const vizRef = useRef(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 ( +
+ +
+ ) +} diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts index 1ee54a5..a3c30ba 100644 --- a/src/plugin-ui/PluginWebViewDataSource.ts +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -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 } diff --git a/src/plugin-ui/ScopeApp.tsx b/src/plugin-ui/ScopeApp.tsx new file mode 100644 index 0000000..b11cba5 --- /dev/null +++ b/src/plugin-ui/ScopeApp.tsx @@ -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 { + 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({ kind, renderScope }: ScopeAppProps): JSX.Element { + const { settings, resolvedTheme, handleUpdate } = useScopeHostSync(kind) + const [settingsOpen, setSettingsOpen] = useState(false) + + return ( +
+ {renderScope(settings, resolvedTheme)} + + + + {settingsOpen && ( +
+ handleUpdate(partial as unknown as Partial)} + /> +
+ )} +
+ ) +} diff --git a/src/plugin-ui/SpectrumApp.tsx b/src/plugin-ui/SpectrumApp.tsx deleted file mode 100644 index 37980b6..0000000 --- a/src/plugin-ui/SpectrumApp.tsx +++ /dev/null @@ -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 - 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)[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 ( - - ) -} - -export default function SpectrumApp({ dataSource, nativeAnalyzer }: SpectrumAppProps): JSX.Element { - const [settings, setSettings] = useState({ ...DEFAULTS }) - const [spectrumTheme, setSpectrumTheme] = useState(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((_kind: K, partial: Partial): void => { - hasOverride.current = true - applySettings({ ...settingsRef.current, ...(partial as Partial) }, true) - }, [applySettings]) - - return ( -
- - - - - {settingsOpen && ( -
- -
- )} -
- ) -} diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts index 49d2b7e..a897e7d 100644 --- a/src/plugin-ui/juceBridge.ts +++ b/src/plugin-ui/juceBridge.ts @@ -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) + } +} diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx index 648c6bc..eb5306c 100644 --- a/src/plugin-ui/main.tsx +++ b/src/plugin-ui/main.tsx @@ -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 -// ) 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 ( + ( + + )} + /> + ) + } + + const analyzer = new BridgeSpectrumAnalyzer(2048) + connectSpectrumBridge({ + onFrame: (frame) => { + analyzer.setMagnitudes(frame.magnitudes, frame.side) + dataSource.setSampleRate(frame.sampleRate) + dataSource.setPlaying(true) + }, + }) + return ( + ( + + )} + /> + ) +} const rootElement = document.getElementById('root') if (!rootElement) { throw new Error('Missing #root element') } -createRoot(rootElement).render( - - - , -) +createRoot(rootElement).render({buildApp()}) diff --git a/src/plugin-ui/oscilloscopeOptions.ts b/src/plugin-ui/oscilloscopeOptions.ts new file mode 100644 index 0000000..0b70b2e --- /dev/null +++ b/src/plugin-ui/oscilloscopeOptions.ts @@ -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, + } +} diff --git a/src/plugin-ui/useScopeHostSync.ts b/src/plugin-ui/useScopeHostSync.ts new file mode 100644 index 0000000..db67e7c --- /dev/null +++ b/src/plugin-ui/useScopeHostSync.ts @@ -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(kind: K, raw: unknown): ScopeSettings[K] { + const defaults = DEFAULT_SCOPE_SETTINGS[kind] as Record + if (typeof raw !== 'object' || raw === null) return { ...defaults } as ScopeSettings[K] + const parsed = raw as Record + const next: Record = { ...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(kind: K, profileJson: string): ScopeSettings[K] { + try { + const parsed = JSON.parse(profileJson) as { scopeSettings?: Record } + 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 { + settings: ScopeSettings[K] + resolvedTheme: PrismResolvedTheme + handleUpdate: (partial: Partial) => 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(kind: K): ScopeHostSync { + const [settings, setSettings] = useState(() => mergeScopeSettings(kind, undefined)) + const [resolvedTheme, setResolvedTheme] = useState(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): void => { + hasOverride.current = true + applySettings({ ...settingsRef.current, ...partial }, true) + }, [applySettings]) + + return { settings, resolvedTheme, handleUpdate } +} diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index 07195c1..2e0564f 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -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) }, diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts index ad8a0fd..d42933b 100644 --- a/src/renderer/visualizers/Oscilloscope.ts +++ b/src/renderer/visualizers/Oscilloscope.ts @@ -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> +type ResolvedOscilloscopeOptions = Required> 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