diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp index 567cb0a..0c66fd4 100644 --- a/plugin/Source/PluginEditor.cpp +++ b/plugin/Source/PluginEditor.cpp @@ -12,6 +12,7 @@ namespace { const juce::Identifier kSpectrumFrameEvent { "spectrumFrame" }; + const juce::Identifier kRestoreSettingsEvent { "prismRestoreSettings" }; constexpr int kDrainCapacity = 16384; #if PRISM_USE_DEV_SERVER @@ -34,8 +35,6 @@ namespace std::optional provideResource(const juce::String& url) { - // "/" -> index.html; otherwise match the request's basename against the - // embedded originals (juce_add_binary_data stores files by basename). auto name = (url == "/") ? juce::String("index.html") : url.fromLastOccurrenceOf("/", false, false); name = name.upToFirstOccurrenceOf("?", false, false); @@ -55,22 +54,33 @@ namespace } #endif - juce::WebBrowserComponent::Options makeWebOptions() + juce::WebBrowserComponent::Options makeWebOptions(PrismSpectrumEditor& editor) { - auto options = juce::WebBrowserComponent::Options{}.withNativeIntegrationEnabled(); + auto options = juce::WebBrowserComponent::Options{} + .withNativeIntegrationEnabled() + .withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); }) + .withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); }); #if ! PRISM_USE_DEV_SERVER - options = options.withResourceProvider ([] (const auto& url) { return provideResource (url); }); + options = options.withResourceProvider([](const auto& url) { return provideResource(url); }); #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()) + webView(makeWebOptions(*this)) { - drainScratch.assign((size_t) kDrainCapacity, 0.0f); + drainLeft.assign((size_t) kDrainCapacity, 0.0f); + drainRight.assign((size_t) kDrainCapacity, 0.0f); addAndMakeVisible(webView); @@ -93,12 +103,91 @@ void PrismSpectrumEditor::resized() webView.setBounds(getLocalBounds()); } +void PrismSpectrumEditor::onPrismConfig(juce::var payload) +{ + // 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()); + + // 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)); +} + +void PrismSpectrumEditor::onPrismReady() +{ + pushRestoreSettings(); + sendAppDefaults(); +} + +void PrismSpectrumEditor::sendAppDefaults() +{ + // macOS userApplicationDataDirectory is ~/Library, so append "Application Support". + const auto appData = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) + .getChildFile("Application Support") + .getChildFile("prism"); + const auto docs = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory); + const auto themesDir = docs.getChildFile("Prism Themes"); + const auto profilesDir = docs.getChildFile("Prism Profiles"); + + 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(); + + if (themeId.isNotEmpty()) + { + const auto file = themesDir.getChildFile(themeId + ".iro"); + if (file.existsAsFile()) + 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()) + activeProfileId = obj->getProperty("activeProfileId").toString(); + + if (activeProfileId.isNotEmpty() && profilesDir.isDirectory()) + { + for (const auto& file : profilesDir.findChildFiles(juce::File::findFiles, false, "*.prsm")) + { + const auto content = file.loadFileAsString(); + if (const auto parsed = juce::JSON::parse(content); auto* o = parsed.getDynamicObject()) + { + if (o->getProperty("id").toString() == activeProfileId) + { + profileJson = content; + break; + } + } + } + } + + auto* payload = new juce::DynamicObject(); + payload->setProperty("themeId", themeId); + payload->setProperty("themeFile", themeFile); + payload->setProperty("profileJson", profileJson); + 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 - // Once the editor is on screen, lift WKWebView's private 60fps cap so the - // canvas repaints at the display's native rate (e.g. 120Hz). Retry a few - // frames until the web view exists in the hierarchy, then stop. if (! frameRateUncapped && uncapAttempts < 300) { ++uncapAttempts; @@ -114,22 +203,20 @@ void PrismSpectrumEditor::renderFrame() lastSampleRate = sampleRate; } - const int drained = processorRef.drainSamples(drainScratch.data(), (int) drainScratch.size()); + const int drained = processorRef.drainStereo(drainLeft.data(), drainRight.data(), (int) drainLeft.size()); if (drained > 0) - spectrum.pushSamples(drainScratch.data(), (size_t) drained); + spectrum.pushStereoSamples(drainLeft.data(), drainRight.data(), (size_t) drained); else - spectrum.pushSamples(nullptr, 0); // recompute so smoothing keeps decaying to silence + spectrum.pushStereoSamples(nullptr, nullptr, 0); // recompute so smoothing keeps decaying - const auto& magnitudes = spectrum.getMagnitudes(); - if (magnitudes.empty()) + const auto& mid = spectrum.getMagnitudes(); + if (mid.empty()) return; - const auto encoded = juce::Base64::toBase64(magnitudes.data(), - magnitudes.size() * sizeof(float)); - auto* payload = new juce::DynamicObject(); payload->setProperty("sampleRate", sampleRate); - payload->setProperty("magnitudes", encoded); + payload->setProperty("magnitudes", floatBufferToBase64(mid)); + payload->setProperty("side", floatBufferToBase64(spectrum.getSideMagnitudes())); webView.emitEventIfBrowserIsVisible(kSpectrumFrameEvent, juce::var(payload)); } diff --git a/plugin/Source/PluginEditor.h b/plugin/Source/PluginEditor.h index c8bd038..e2f7d7d 100644 --- a/plugin/Source/PluginEditor.h +++ b/plugin/Source/PluginEditor.h @@ -9,10 +9,10 @@ * Hosts the React webview UI and bridges the reused Prism spectrum DSP to it. * * Driven by a VBlankAttachment (message thread, synced to the display's refresh - * rate — so it adapts to 60/120/144 Hz panels). Each vblank it drains audio - * buffered by the processor, runs Visualizer::Spectrum (native/src/spectrum.cpp), - * and emits magnitudes to the webview as a "spectrumFrame" event. The webview - * (src/plugin-ui) renders them with the existing SpectrumAnalyzer canvas code. + * rate). Each vblank it drains stereo audio buffered by the processor, runs + * Visualizer::Spectrum, and emits mid + side magnitudes to the webview as a + * "spectrumFrame" event. Receives "prismConfig" (settings + fftSize/smoothing) + * and "prismReady" events from the UI. */ class PrismSpectrumEditor : public juce::AudioProcessorEditor { @@ -22,13 +22,25 @@ public: void resized() override; + // Called by the webview event listeners (message thread). + void onPrismConfig(juce::var payload); + void onPrismReady(); + + // Push the processor's saved settings to the UI (used on ready + on host + // state restore, to cover either ordering). + void pushRestoreSettings(); + + // Read the user's Prism app theme + active profile from disk and send them + // to the UI as defaults (per-instance overrides still win). + void sendAppDefaults(); + private: void renderFrame(); PrismSpectrumProcessor& processorRef; Visualizer::Spectrum spectrum { 2048 }; - std::vector drainScratch; + std::vector drainLeft, drainRight; double lastSampleRate = 0.0; // One-time attempt to lift WKWebView's private 60fps cap (macOS). diff --git a/plugin/Source/PluginProcessor.cpp b/plugin/Source/PluginProcessor.cpp index 252885c..1a8c299 100644 --- a/plugin/Source/PluginProcessor.cpp +++ b/plugin/Source/PluginProcessor.cpp @@ -7,13 +7,13 @@ PrismSpectrumProcessor::PrismSpectrumProcessor() .withInput("Input", juce::AudioChannelSet::stereo(), true) .withOutput("Output", juce::AudioChannelSet::stereo(), true)) { - fifoBuffer.assign((size_t) fifo.getTotalSize(), 0.0f); + leftBuffer.assign((size_t) fifo.getTotalSize(), 0.0f); + rightBuffer.assign((size_t) fifo.getTotalSize(), 0.0f); } -void PrismSpectrumProcessor::prepareToPlay(double sampleRate, int samplesPerBlock) +void PrismSpectrumProcessor::prepareToPlay(double sampleRate, int) { currentSampleRate.store(sampleRate); - monoScratch.assign((size_t) juce::jmax(samplesPerBlock, 1), 0.0f); fifo.reset(); } @@ -27,26 +27,38 @@ bool PrismSpectrumProcessor::isBusesLayoutSupported(const BusesLayout& layouts) return mainOut == layouts.getMainInputChannelSet(); } -void PrismSpectrumProcessor::pushMonoToFifo(const float* data, int num) noexcept +void PrismSpectrumProcessor::pushStereoToFifo(const float* left, const float* right, int num) noexcept { int start1, size1, start2, size2; fifo.prepareToWrite(num, start1, size1, start2, size2); if (size1 > 0) - std::memcpy(fifoBuffer.data() + start1, data, (size_t) size1 * sizeof(float)); + { + std::memcpy(leftBuffer.data() + start1, left, (size_t) size1 * sizeof(float)); + std::memcpy(rightBuffer.data() + start1, right, (size_t) size1 * sizeof(float)); + } if (size2 > 0) - std::memcpy(fifoBuffer.data() + start2, data + size1, (size_t) size2 * sizeof(float)); + { + std::memcpy(leftBuffer.data() + start2, left + size1, (size_t) size2 * sizeof(float)); + std::memcpy(rightBuffer.data() + start2, right + size1, (size_t) size2 * sizeof(float)); + } fifo.finishedWrite(size1 + size2); } -int PrismSpectrumProcessor::drainSamples(float* dest, int maxSamples) noexcept +int PrismSpectrumProcessor::drainStereo(float* destLeft, float* destRight, int maxSamples) noexcept { const int num = juce::jmin(maxSamples, fifo.getNumReady()); int start1, size1, start2, size2; fifo.prepareToRead(num, start1, size1, start2, size2); if (size1 > 0) - std::memcpy(dest, fifoBuffer.data() + start1, (size_t) size1 * sizeof(float)); + { + std::memcpy(destLeft, leftBuffer.data() + start1, (size_t) size1 * sizeof(float)); + std::memcpy(destRight, rightBuffer.data() + start1, (size_t) size1 * sizeof(float)); + } if (size2 > 0) - std::memcpy(dest + size1, fifoBuffer.data() + start2, (size_t) size2 * sizeof(float)); + { + std::memcpy(destLeft + size1, leftBuffer.data() + start2, (size_t) size2 * sizeof(float)); + std::memcpy(destRight + size1, rightBuffer.data() + start2, (size_t) size2 * sizeof(float)); + } fifo.finishedRead(size1 + size2); return size1 + size2; } @@ -60,27 +72,42 @@ void PrismSpectrumProcessor::processBlock(juce::AudioBuffer& buffer, juce if (numSamples <= 0 || numChannels <= 0) return; - if ((int) monoScratch.size() < numSamples) - monoScratch.resize((size_t) numSamples); - - if (numChannels >= 2) - { - const float* left = buffer.getReadPointer(0); - const float* right = buffer.getReadPointer(1); - for (int i = 0; i < numSamples; ++i) - monoScratch[(size_t) i] = 0.5f * (left[i] + right[i]); - } - else - { - const float* mono = buffer.getReadPointer(0); - for (int i = 0; i < numSamples; ++i) - monoScratch[(size_t) i] = mono[i]; - } - - pushMonoToFifo(monoScratch.data(), numSamples); + const float* left = buffer.getReadPointer(0); + const float* right = numChannels >= 2 ? buffer.getReadPointer(1) : left; + pushStereoToFifo(left, right, numSamples); // Pure analyzer: the audio buffer is left untouched (pass-through). - juce::ignoreUnused(numChannels); +} + +void PrismSpectrumProcessor::setSettingsJson(const juce::String& json) +{ + const juce::ScopedLock sl(settingsLock); + settingsJson = json; +} + +juce::String PrismSpectrumProcessor::getSettingsJson() const +{ + const juce::ScopedLock sl(settingsLock); + return settingsJson; +} + +void PrismSpectrumProcessor::getStateInformation(juce::MemoryBlock& destData) +{ + const juce::String json = getSettingsJson(); + destData.setSize(0); + destData.append(json.toRawUTF8(), json.getNumBytesAsUTF8()); +} + +void PrismSpectrumProcessor::setStateInformation(const void* data, int sizeInBytes) +{ + if (data == nullptr || sizeInBytes <= 0) + return; + setSettingsJson(juce::String::fromUTF8(static_cast(data), sizeInBytes)); + + // If the editor is already open (host restored state after opening it), push + // the settings to the UI now — the prismReady reply alone would have missed it. + if (auto* editor = dynamic_cast(getActiveEditor())) + editor->pushRestoreSettings(); } juce::AudioProcessorEditor* PrismSpectrumProcessor::createEditor() diff --git a/plugin/Source/PluginProcessor.h b/plugin/Source/PluginProcessor.h index d6ddc09..8978337 100644 --- a/plugin/Source/PluginProcessor.h +++ b/plugin/Source/PluginProcessor.h @@ -7,10 +7,13 @@ /** * Prism Spectrum — analyzer plugin. * - * Passes audio through unchanged. On the realtime thread (processBlock) it mixes - * the input to mono and writes it into a lock-free FIFO. The editor's timer drains - * the FIFO off the realtime thread, runs the reused Prism DSP (native/src/spectrum.cpp), - * and pushes magnitudes to the webview. No DSP or allocation happens on the audio thread. + * Passes audio through unchanged. On the realtime thread (processBlock) it writes + * the input's L/R into a lock-free FIFO. The editor's frame callback drains the + * FIFO off the realtime thread, runs the reused Prism DSP (native/src/spectrum.cpp) + * as stereo (so mid + side are available), and pushes magnitudes to the webview. + * + * UI settings (JSON) are owned here so they survive editor open/close and DAW + * session save/restore. */ class PrismSpectrumProcessor : public juce::AudioProcessor { @@ -38,22 +41,27 @@ public: const juce::String getProgramName(int) override { return {}; } void changeProgramName(int, const juce::String&) override {} - void getStateInformation(juce::MemoryBlock&) override {} - void setStateInformation(const void*, int) override {} + void getStateInformation(juce::MemoryBlock&) override; + void setStateInformation(const void*, int) override; - /** Latest negotiated sample rate (read from the message thread). */ double getSampleRateHz() const noexcept { return currentSampleRate.load(); } - /** Copy up to `maxSamples` of buffered mono audio into `dest`; returns count written. */ - int drainSamples(float* dest, int maxSamples) noexcept; + /** Copy up to `maxSamples` of buffered L/R audio into the destinations; returns count. */ + int drainStereo(float* destLeft, float* destRight, int maxSamples) noexcept; + + /** Persisted UI settings as a JSON string (set from the editor, read on save). */ + void setSettingsJson(const juce::String& json); + juce::String getSettingsJson() const; private: - void pushMonoToFifo(const float* data, int num) noexcept; + void pushStereoToFifo(const float* left, const float* right, int num) noexcept; juce::AbstractFifo fifo { 1 << 16 }; - std::vector fifoBuffer; // backing storage for `fifo` - std::vector monoScratch; // realtime-thread mono mixdown buffer + std::vector leftBuffer, rightBuffer; // backing storage for `fifo` std::atomic currentSampleRate { 48000.0 }; + juce::CriticalSection settingsLock; + juce::String settingsJson; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismSpectrumProcessor) }; diff --git a/src/plugin-ui/BridgeSpectrumAnalyzer.ts b/src/plugin-ui/BridgeSpectrumAnalyzer.ts index 003fb6d..672a919 100644 --- a/src/plugin-ui/BridgeSpectrumAnalyzer.ts +++ b/src/plugin-ui/BridgeSpectrumAnalyzer.ts @@ -19,18 +19,27 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { private fftSize = 2048 private sampleRate = 48000 private magnitudes: Float32Array + private sideMagnitudes: Float32Array constructor(fftSize = 2048) { this.fftSize = fftSize this.magnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB) + this.sideMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB) } /** Called by the bridge whenever the host emits a new frame. */ - setMagnitudes(magnitudes: Float32Array): void { + setMagnitudes(magnitudes: Float32Array, side?: Float32Array): void { if (magnitudes.length !== this.magnitudes.length) { this.magnitudes = new Float32Array(magnitudes.length) } this.magnitudes.set(magnitudes) + + if (side && side.length > 0) { + if (side.length !== this.sideMagnitudes.length) { + this.sideMagnitudes = new Float32Array(side.length) + } + this.sideMagnitudes.set(side) + } } isAvailable(): boolean { @@ -41,6 +50,7 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { if (size > 0 && size !== this.fftSize) { this.fftSize = size this.magnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB) + this.sideMagnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB) } } @@ -67,16 +77,17 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { return count } - // The C++ side currently sends a single (already smoothed) magnitude array. - // Serve it for the raw/side requests too; the heatmap + side-line paths can be - // wired to dedicated host arrays in a later phase. + // The C++ side sends already-smoothed magnitudes; serve them for the raw + // request too (the heatmap path re-smooths from this in the visualizer). fillRawMagnitudes(output: Float32Array): number { return this.fillMagnitudes(output) } fillSideMagnitudes(output: Float32Array): number { - const count = Math.min(output.length, this.magnitudes.length) - output.fill(FFT_SILENCE_DB, 0, count) + const count = Math.min(output.length, this.sideMagnitudes.length) + if (count > 0) { + output.set(this.sideMagnitudes.subarray(0, count), 0) + } return count } @@ -88,8 +99,8 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { return this.magnitudes } - getSideMagnitudes(): Float32Array | null { - return null + getSideMagnitudes(): Float32Array { + return this.sideMagnitudes } process(_audioData: Float32Array): Float32Array { @@ -102,5 +113,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { reset(): void { this.magnitudes.fill(FFT_SILENCE_DB) + this.sideMagnitudes.fill(FFT_SILENCE_DB) } } diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts index 65d8f1d..1ee54a5 100644 --- a/src/plugin-ui/PluginWebViewDataSource.ts +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -25,12 +25,25 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource { private readonly listeners = new Set<(state: ScopePopoutSessionState) => void>() + // The DSP runs in C++ and pushes finished magnitudes (no raw samples flow + // through here). But SpectrumAnalyzer only refreshes its heatmap buffer when + // it sees "new samples arrived" (a non-empty pending queue). We therefore + // hand it a reusable sentinel chunk each frame to signal a fresh frame. Its + // length saturates `nativeBufferedSamples` so heatmap smoothing applies — the + // values are unused (the shim's pushSamples is a no-op; magnitudes come from + // fillMagnitudes). Sized to the max FFT so any fftSize saturates in one frame. + private readonly sentinel = new Float32Array(16384) + private readonly sentinelStereo: SpectrumStereoChunk = { + left: this.sentinel, + right: this.sentinel, + } + getPendingSpectrumSamples(): Float32Array[] { - return [] + return this.sessionState.capturing ? [this.sentinel] : [] } getPendingSpectrumStereoSamples(): SpectrumStereoChunk[] { - return [] + return this.sessionState.capturing ? [this.sentinelStereo] : [] } getSampleRate(): number { diff --git a/src/plugin-ui/SpectrumApp.tsx b/src/plugin-ui/SpectrumApp.tsx new file mode 100644 index 0000000..37980b6 --- /dev/null +++ b/src/plugin-ui/SpectrumApp.tsx @@ -0,0 +1,145 @@ +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/SpectrumScope.tsx b/src/plugin-ui/SpectrumScope.tsx index 1d4db6c..4d1c511 100644 --- a/src/plugin-ui/SpectrumScope.tsx +++ b/src/plugin-ui/SpectrumScope.tsx @@ -1,115 +1,129 @@ -import { useEffect, useRef, useState, type JSX } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react' import { SpectrumAnalyzer } from '../renderer/visualizers/SpectrumAnalyzer' +import type { ScopeSettings } from '../types/settings' +import type { ResolvedSpectrumTheme } from '../types/theme' +import type { SpectrumPeakInfo } from '../types/spectrum' import type { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer' import type { PluginWebViewDataSource } from './PluginWebViewDataSource' +import { spectrumSettingsToOptions } from './spectrumOptions' +import { + formatSpectrumPeakDb, + formatSpectrumPeakFrequency, + measureCanvasResizeState, + resolveFollowingPeakOverlayStyle, + type CanvasResizeState, + type SizeMeasurement, +} from './peakOverlay' interface SpectrumScopeProps { dataSource: PluginWebViewDataSource nativeAnalyzer: BridgeSpectrumAnalyzer - /** Returns the cumulative count of frames received from the host (for the FPS meter). */ - getDataFrameCount?: () => number - /** Show the render/data FPS diagnostic overlay. */ - showFpsMeter?: boolean -} - -// POC visual defaults. A later phase can sync these to Prism's theme/settings -// (the same `scopeSettingsToOptions` mapping ScopeModule already uses). -const VISUAL_OPTIONS = { - lineColor: '#22d3ee', - lineWidth: 2, - fillGradient: true, - gradientColors: ['rgba(34, 211, 238, 0)', 'rgba(34, 211, 238, 0.25)', 'rgba(139, 92, 246, 0.45)'], - backgroundColor: 'transparent', - showGrid: true, - gridColor: 'rgba(255, 255, 255, 0.1)', - scaleType: 'log' as const, - fftSize: 2048, - minFrequency: 20, - maxFrequency: 20000, - minDecibels: -90, - maxDecibels: -10, + settings: ScopeSettings['spectrum'] + theme: ResolvedSpectrumTheme } export default function SpectrumScope({ dataSource, nativeAnalyzer, - getDataFrameCount, - showFpsMeter = false, + settings, + theme, }: SpectrumScopeProps): JSX.Element { const containerRef = useRef(null) const canvasRef = useRef(null) - const [fps, setFps] = useState({ render: 0, data: 0 }) + const analyzerRef = useRef(null) + const resizeStateRef = useRef(null) + const peakOverlayRef = useRef(null) + const [peak, setPeak] = useState(null) + const [overlaySize, setOverlaySize] = useState(null) + const peakMode = settings.peakInfoMode + + // Create the analyzer once per data source / shim. useEffect(() => { const container = containerRef.current const canvas = canvasRef.current if (!container || !canvas) return const analyzer = new SpectrumAnalyzer(canvas, { - ...VISUAL_OPTIONS, + ...spectrumSettingsToOptions(settings, theme), + capturePeakInfo: settings.peakInfoMode !== 'off', + onPeakInfo: setPeak, dataSource, nativeAnalyzer, }) + analyzerRef.current = analyzer 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 + const state = measureCanvasResizeState(container) + resizeStateRef.current = state + if (canvas.width !== state.pixelWidth || canvas.height !== state.pixelHeight) { + canvas.width = state.pixelWidth + canvas.height = state.pixelHeight analyzer.resize() } } applySize() analyzer.start() - const observer = new ResizeObserver(applySize) observer.observe(container) return () => { observer.disconnect() analyzer.dispose() + analyzerRef.current = null } + // settings/theme are applied via setOptions below, not on recreation. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [dataSource, nativeAnalyzer]) - // Independent FPS meter: counts our own rAF ticks (the webview's actual render - // rate) and the host data-frame delta over each measurement window. + // Apply settings/theme changes live. useEffect(() => { - if (!showFpsMeter) return - let raf = 0 - let renderCount = 0 - let lastData = getDataFrameCount?.() ?? 0 - let lastT = performance.now() + if (settings.peakInfoMode === 'off') setPeak(null) + analyzerRef.current?.setOptions({ + ...spectrumSettingsToOptions(settings, theme), + capturePeakInfo: settings.peakInfoMode !== 'off', + onPeakInfo: setPeak, + }) + }, [settings, theme]) - const loop = (t: number): void => { - renderCount += 1 - const elapsed = t - lastT - if (elapsed >= 500) { - const dataNow = getDataFrameCount?.() ?? 0 - const seconds = elapsed / 1000 - setFps({ - render: Math.round(renderCount / seconds), - data: Math.round((dataNow - lastData) / seconds), - }) - renderCount = 0 - lastData = dataNow - lastT = t - } - raf = requestAnimationFrame(loop) + // Measure the overlay so "following" placement can avoid the screen edges. + useLayoutEffect(() => { + const overlay = peakOverlayRef.current + if (peakMode !== 'following' || !peak || !overlay) { + setOverlaySize(null) + return } - raf = requestAnimationFrame(loop) - return () => cancelAnimationFrame(raf) - }, [showFpsMeter, getDataFrameCount]) + const measure = (): void => { + const next = { width: overlay.offsetWidth, height: overlay.offsetHeight } + setOverlaySize((prev) => (prev?.width === next.width && prev?.height === next.height ? prev : next)) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(overlay) + return () => observer.disconnect() + }, [peakMode, peak]) + + const showPeak = peakMode !== 'off' && peak !== null + const overlayStyle: CSSProperties | undefined = + peakMode === 'following' && peak + ? resolveFollowingPeakOverlayStyle(peak, resizeStateRef.current, overlaySize) + : undefined return (
- {showFpsMeter && ( -
- render {fps.render} fps · data {fps.data} fps · dpr {window.devicePixelRatio || 1} + {showPeak && peak && ( +
+ {formatSpectrumPeakDb(peak.db)} + / + {formatSpectrumPeakFrequency(peak.frequencyHz)} + / + {peak.key}
)}
diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts index d3a70de..49d2b7e 100644 --- a/src/plugin-ui/juceBridge.ts +++ b/src/plugin-ui/juceBridge.ts @@ -1,157 +1,167 @@ /** * Bridge between the JUCE 8 plugin host (C++) and this webview UI. * - * The C++ side (plugin/Source/PluginEditor.cpp) computes spectrum magnitudes off - * the realtime thread and emits a "spectrumFrame" event ~60x/sec via - * juce::WebBrowserComponent::emitEventIfBrowserIsVisible(). JUCE injects - * `window.__JUCE__` into any page loaded by the webview (including the Vite dev - * server) when native integration is enabled, so we subscribe to that here. + * - C++ -> JS: emits "spectrumFrame" (~display rate) and "prismRestoreSettings". + * - JS -> C++: emits "prismConfig" (settings + DSP params) and "prismReady". * - * When `window.__JUCE__` is absent (e.g. opening the dev server in a normal - * browser), we fall back to a synthetic generator so the UI is still developable - * outside a DAW. + * JUCE injects `window.__JUCE__` into pages loaded by the webview (including the + * Vite dev server) when native integration is enabled. When it's absent (e.g. a + * plain browser), a synthetic generator drives the UI so it's developable. */ export interface SpectrumFrame { /** Host sample rate in Hz. */ sampleRate: number - /** Smoothed magnitudes in dB, length = fftSize/2 (1024 for a 2048 FFT). */ + /** Mid (mono) magnitudes in dB, length = fftSize/2. */ magnitudes: Float32Array + /** Side magnitudes in dB (same length); empty if unavailable. */ + side: Float32Array } -/** Raw payload as it arrives from C++ across the JUCE var bridge. */ interface SpectrumFramePayload { sampleRate?: number - /** base64 of little-endian Float32 magnitude bytes. */ magnitudes?: string + side?: string } type JuceBackend = { addEventListener: (eventId: string, fn: (payload: unknown) => void) => number removeEventListener?: (id: number) => void + emitEvent?: (eventId: string, payload: unknown) => void } declare global { interface Window { - __JUCE__?: { - backend?: JuceBackend - initialisationData?: unknown + __JUCE__?: { backend?: JuceBackend; initialisationData?: unknown } + } +} + +const HOST_WAIT_TIMEOUT_MS = 4000 +const HOST_POLL_INTERVAL_MS = 50 + +/** Resolves to the JUCE backend once available, or null if no host (timeout). */ +let backendPromise: Promise | null = null + +function ensureBackend(): Promise { + if (backendPromise) return backendPromise + backendPromise = new Promise((resolve) => { + const existing = window.__JUCE__?.backend + if (existing && typeof existing.addEventListener === 'function') { + resolve(existing) + return + } + let waited = 0 + const timer = setInterval(() => { + const backend = window.__JUCE__?.backend + if (backend && typeof backend.addEventListener === 'function') { + clearInterval(timer) + resolve(backend) + return + } + waited += HOST_POLL_INTERVAL_MS + if (waited >= HOST_WAIT_TIMEOUT_MS) { + clearInterval(timer) + resolve(null) + } + }, HOST_POLL_INTERVAL_MS) + }) + return backendPromise +} + +/** Fire-and-forget event to C++ (no-op when running without a host). */ +export function emitToHost(eventId: string, payload: unknown): void { + void ensureBackend().then((backend) => backend?.emitEvent?.(eventId, payload)) +} + +/** Subscribe to a C++ event. Returns an unsubscribe function. */ +export function onHostEvent(eventId: string, handler: (payload: unknown) => void): () => void { + let listenerId: number | null = null + let cancelled = false + void ensureBackend().then((backend) => { + if (!backend || cancelled) return + listenerId = backend.addEventListener(eventId, handler) + }) + return () => { + cancelled = true + if (listenerId !== null) { + window.__JUCE__?.backend?.removeEventListener?.(listenerId) } } } -const SPECTRUM_EVENT_ID = 'spectrumFrame' -const HOST_WAIT_TIMEOUT_MS = 3000 -const HOST_POLL_INTERVAL_MS = 50 - -/** Decode a base64 string of little-endian Float32 bytes into a Float32Array. */ function base64ToFloat32Array(b64: string): Float32Array { + if (!b64) return new Float32Array(0) const binary = atob(b64) const byteLength = binary.length const bytes = new Uint8Array(byteLength) for (let i = 0; i < byteLength; i += 1) { bytes[i] = binary.charCodeAt(i) } - // byteLength is a multiple of 4 (Float32) when produced by the C++ side. return new Float32Array(bytes.buffer, 0, byteLength >> 2) } function decodeFrame(payload: unknown): SpectrumFrame | null { - if (typeof payload !== 'object' || payload === null) { - return null - } - const { sampleRate, magnitudes } = payload as SpectrumFramePayload - if (typeof magnitudes !== 'string' || magnitudes.length === 0) { - return null - } + if (typeof payload !== 'object' || payload === null) return null + const { sampleRate, magnitudes, side } = payload as SpectrumFramePayload + if (typeof magnitudes !== 'string' || magnitudes.length === 0) return null return { sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000, magnitudes: base64ToFloat32Array(magnitudes), + side: typeof side === 'string' ? base64ToFloat32Array(side) : new Float32Array(0), } } export interface SpectrumBridgeHandlers { - /** Called for every decoded frame (from the host or the mock generator). */ onFrame: (frame: SpectrumFrame) => void - /** Called once we know whether a real JUCE host is present. */ onConnected?: (usingMock: boolean) => void } -/** - * Connect to the JUCE host. Resolves to a disposer that detaches the listener - * (or stops the mock generator). - */ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => void { let disposed = false let listenerId: number | null = null let mockRaf: number | null = null - let pollTimer: ReturnType | null = null - let waited = 0 - - const attachToHost = (backend: JuceBackend): void => { - listenerId = backend.addEventListener(SPECTRUM_EVENT_ID, (payload) => { - const frame = decodeFrame(payload) - if (frame) { - handlers.onFrame(frame) - } - }) - handlers.onConnected?.(false) - console.log('[prism-plugin] connected to JUCE host') - } const startMock = (): void => { handlers.onConnected?.(true) - console.warn('[prism-plugin] no JUCE host detected — using synthetic spectrum (browser dev mode)') + console.warn('[prism-plugin] no JUCE host — using synthetic spectrum (browser dev mode)') const binCount = 1024 const sampleRate = 48000 - const data = new Float32Array(binCount) + const mid = new Float32Array(binCount) + const side = new Float32Array(binCount).fill(-100) let phase = 0 const tick = (): void => { if (disposed) return phase += 0.05 for (let i = 0; i < binCount; i += 1) { const t = i / binCount - // A couple of moving peaks over a -100 dB noise floor. const peak1 = Math.exp(-Math.pow((t - (0.15 + 0.05 * Math.sin(phase))) * 12, 2)) * 70 const peak2 = Math.exp(-Math.pow((t - 0.5) * 18, 2)) * 50 - const noise = Math.random() * 6 - data[i] = -100 + peak1 + peak2 + noise + mid[i] = -100 + peak1 + peak2 + Math.random() * 6 + side[i] = -100 + peak2 * 0.4 + Math.random() * 4 } - handlers.onFrame({ sampleRate, magnitudes: data }) + handlers.onFrame({ sampleRate, magnitudes: mid, side }) mockRaf = requestAnimationFrame(tick) } mockRaf = requestAnimationFrame(tick) } - const tryConnect = (): void => { + void ensureBackend().then((backend) => { if (disposed) return - const backend = window.__JUCE__?.backend - if (backend && typeof backend.addEventListener === 'function') { - if (pollTimer) clearInterval(pollTimer) - pollTimer = null - attachToHost(backend) - return - } - waited += HOST_POLL_INTERVAL_MS - if (waited >= HOST_WAIT_TIMEOUT_MS) { - if (pollTimer) clearInterval(pollTimer) - pollTimer = null + if (backend) { + listenerId = backend.addEventListener('spectrumFrame', (payload) => { + const frame = decodeFrame(payload) + if (frame) handlers.onFrame(frame) + }) + handlers.onConnected?.(false) + console.log('[prism-plugin] connected to JUCE host') + } else { startMock() } - } - - // Try immediately, then poll briefly (the __JUCE__ script may inject slightly late). - tryConnect() - if (listenerId === null && mockRaf === null) { - pollTimer = setInterval(tryConnect, HOST_POLL_INTERVAL_MS) - } + }) return () => { disposed = true - if (pollTimer) clearInterval(pollTimer) if (mockRaf !== null) cancelAnimationFrame(mockRaf) - if (listenerId !== null) { - window.__JUCE__?.backend?.removeEventListener?.(listenerId) - } + if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId) } } diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx index d828b1a..648c6bc 100644 --- a/src/plugin-ui/main.tsx +++ b/src/plugin-ui/main.tsx @@ -1,25 +1,21 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import SpectrumScope from './SpectrumScope' +import '../renderer/styles/globals.css' +import './styles.css' +import SpectrumApp from './SpectrumApp' import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer' import { PluginWebViewDataSource } from './PluginWebViewDataSource' import { connectSpectrumBridge } from './juceBridge' -import './styles.css' // One shared analyzer shim + data source for the lifetime of the page. const nativeAnalyzer = new BridgeSpectrumAnalyzer(2048) const dataSource = new PluginWebViewDataSource() -// Diagnostic: count host frames so the FPS meter (off by default) can show the -// data push rate. Toggle it on by adding `showFpsMeter` to . -let dataFrameCount = 0 - // Pipe host frames into the shim/data source. The SpectrumAnalyzer (mounted by -// ) reads from both on its own render loop. +// ) reads from both on its own render loop. connectSpectrumBridge({ onFrame: (frame) => { - dataFrameCount += 1 - nativeAnalyzer.setMagnitudes(frame.magnitudes) + nativeAnalyzer.setMagnitudes(frame.magnitudes, frame.side) dataSource.setSampleRate(frame.sampleRate) dataSource.setPlaying(true) }, @@ -32,10 +28,6 @@ if (!rootElement) { createRoot(rootElement).render( - dataFrameCount} - /> + , ) diff --git a/src/plugin-ui/peakOverlay.ts b/src/plugin-ui/peakOverlay.ts new file mode 100644 index 0000000..0854171 --- /dev/null +++ b/src/plugin-ui/peakOverlay.ts @@ -0,0 +1,102 @@ +import type { CSSProperties } from 'react' +import type { SpectrumPeakInfo } from '../types/spectrum' + +/** + * Peak-overlay positioning + formatting, mirroring ScopeModule.tsx so the plugin's + * "following" peak readout behaves exactly like the Prism app. Kept as a local + * copy (pure functions) so the plugin doesn't import the heavy ScopeModule. + */ + +export interface CanvasResizeState { + cssWidth: number + cssHeight: number + pixelWidth: number + pixelHeight: number + dpr: number +} + +export interface SizeMeasurement { + width: number + height: number +} + +const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10 +const SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX = 248 +const SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX = 42 + +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +export function formatSpectrumPeakDb(value: number): string { + if (!Number.isFinite(value)) { + return '--' + } + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dB` +} + +export function formatSpectrumPeakFrequency(value: number): string { + if (!Number.isFinite(value) || value <= 0) { + return '--' + } + if (value >= 1000) { + return `${(value / 1000).toFixed(2)}kHz` + } + return `${value.toFixed(2)}Hz` +} + +export function measureCanvasResizeState(container: HTMLElement): CanvasResizeState { + const rect = container.getBoundingClientRect() + const cssWidth = Math.max(1, Math.floor(rect.width)) + const cssHeight = Math.max(1, Math.floor(rect.height)) + const dpr = window.devicePixelRatio || 1 + + return { + cssWidth, + cssHeight, + pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)), + pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)), + dpr, + } +} + +export function resolveFollowingPeakOverlayStyle( + peakInfo: SpectrumPeakInfo, + resizeState: CanvasResizeState | null, + overlaySize: SizeMeasurement | null, +): CSSProperties { + if (!resizeState) { + return { + left: `${SPECTRUM_PEAK_OVERLAY_MARGIN_PX}px`, + top: `${SPECTRUM_PEAK_OVERLAY_MARGIN_PX}px`, + } + } + + const width = resizeState.cssWidth + const height = resizeState.cssHeight + const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX + const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX + const peakX = peakInfo.normalizedX * width + const peakY = peakInfo.normalizedY * height + const maxLeft = Math.max( + SPECTRUM_PEAK_OVERLAY_MARGIN_PX, + width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX, + ) + const maxTop = Math.max( + SPECTRUM_PEAK_OVERLAY_MARGIN_PX, + height - overlayHeight - SPECTRUM_PEAK_OVERLAY_MARGIN_PX, + ) + + const canPlaceAbove = peakY - overlayHeight >= SPECTRUM_PEAK_OVERLAY_MARGIN_PX + const canPlaceBelow = peakY + overlayHeight <= height - SPECTRUM_PEAK_OVERLAY_MARGIN_PX + + const left = peakX + const top = canPlaceAbove || !canPlaceBelow + ? peakY - overlayHeight + : peakY + + return { + left: `${clampNumber(left, SPECTRUM_PEAK_OVERLAY_MARGIN_PX, maxLeft)}px`, + top: `${clampNumber(top, SPECTRUM_PEAK_OVERLAY_MARGIN_PX, maxTop)}px`, + } +} diff --git a/src/plugin-ui/spectrumOptions.ts b/src/plugin-ui/spectrumOptions.ts new file mode 100644 index 0000000..597238a --- /dev/null +++ b/src/plugin-ui/spectrumOptions.ts @@ -0,0 +1,32 @@ +import type { ScopeSettings } from '../types/settings' +import type { ResolvedSpectrumTheme } from '../types/theme' +import type { SpectrumAnalyzerOptions } from '../renderer/visualizers/SpectrumAnalyzer' + +/** + * Map Prism's spectrum settings + resolved theme to SpectrumAnalyzer options. + * Mirrors the `spectrum` case of `scopeSettingsToOptions` in ScopeModule.tsx — + * kept local so the spectrum plugin doesn't pull in every other visualizer. + */ +export function spectrumSettingsToOptions( + settings: ScopeSettings['spectrum'], + theme: ResolvedSpectrumTheme, +): SpectrumAnalyzerOptions { + return { + lineColor: theme.line, + secondaryLineColor: theme.sideLine, + gradientColors: theme.fillGradient, + heatColors: theme.heatColors, + heatBaseColor: theme.heatBase, + backgroundColor: theme.background, + gridColor: theme.guides, + fftSize: settings.fftSize, + tiltDbPerOctave: settings.tiltDbPerOctave, + heatmapFill: settings.heatmap, + heatmapTiltDbPerOctave: settings.heatmapTiltDbPerOctave, + heatmapSmoothing: settings.heatmapSmoothing, + showGrid: settings.showGrid, + fillGradient: settings.fillGradient, + smoothing: settings.smoothing, + showSideLine: settings.showSideLine, + } +} diff --git a/src/plugin-ui/styles.css b/src/plugin-ui/styles.css index 9c0b248..8515847 100644 --- a/src/plugin-ui/styles.css +++ b/src/plugin-ui/styles.css @@ -17,16 +17,21 @@ body, } body { - background: #0a0a0f; + background: #000; font-family: 'Inter', system-ui, sans-serif; } -.spectrum-scope { +.spectrum-app { position: relative; width: 100%; height: 100%; } +.spectrum-scope { + position: absolute; + inset: 0; +} + .spectrum-scope__canvas { position: absolute; inset: 0; @@ -35,15 +40,83 @@ body { display: block; } -.spectrum-scope__fps { +/* Settings gear — appears on hover (like the app's scope chrome). */ +.spectrum-app__gear { position: absolute; - top: 6px; - left: 8px; - padding: 2px 6px; - border-radius: 4px; - background: rgba(0, 0, 0, 0.45); - color: #7dd3fc; - font: 11px/1.4 'JetBrains Mono', ui-monospace, monospace; - pointer-events: none; - user-select: none; + top: 8px; + right: 8px; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: var(--text-secondary, rgba(255, 255, 255, 0.62)); + background: var(--control-bg, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--control-border, rgba(255, 255, 255, 0.08)); + border-radius: 7px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease; +} + +.spectrum-app:hover .spectrum-app__gear, +.spectrum-app__gear.is-active { + opacity: 1; +} + +.spectrum-app__gear:hover { + color: var(--text-primary, #fff); + background: var(--control-bg-hover, rgba(255, 255, 255, 0.08)); +} + +.spectrum-app__gear.is-active { + color: var(--accent, #38bdf8); + border-color: var(--control-border-active, rgba(56, 189, 248, 0.4)); +} + +.spectrum-app__settings { + position: absolute; + top: 44px; + right: 8px; + z-index: 5; + width: 320px; + max-width: calc(100% - 16px); + max-height: calc(100% - 56px); + overflow-y: auto; + padding: 12px; + background: var(--settings-bg-top, rgba(8, 10, 14, 0.96)); + border: 1px solid var(--panel-outline, rgba(255, 255, 255, 0.12)); + border-radius: 10px; + box-shadow: var(--panel-shadow, 0 14px 34px rgba(0, 0, 0, 0.4)); + backdrop-filter: blur(12px); +} + +.spectrum-scope__peak { + position: absolute; + z-index: 4; + pointer-events: none; + display: flex; + gap: 5px; + align-items: center; + padding: 3px 8px; + border-radius: 6px; + font: 11px/1.3 'JetBrains Mono', ui-monospace, monospace; + color: var(--scope-overlay-text, rgba(255, 255, 255, 0.82)); + background: var(--scope-overlay-surface, rgba(8, 12, 18, 0.82)); + border: 1px solid var(--scope-overlay-border, rgba(255, 255, 255, 0.12)); +} + +.spectrum-scope__peak.is-corner { + top: 8px; + left: 8px; +} + +.spectrum-scope__peak.is-following { + transform: translate(-50%, -120%); +} + +.spectrum-scope__peak-sep { + opacity: 0.4; }