diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt index 6bb26db..eac67fc 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt @@ -24,5 +24,10 @@ class AstraScopeModule : Module() { Function("getSpectrumFrame") { out: Float32Array -> ScopeBridge.nativeFillSpectrum(out.toDirectBuffer(), out.length) } + + // Fill `out` with render-ready oscilloscope points (~[-1,1]). + Function("getOscilloscopeFrame") { out: Float32Array -> + ScopeBridge.nativeFillOscilloscope(out.toDirectBuffer(), out.length) + } } } diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/ScopeBridge.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/ScopeBridge.kt index 152f38b..b885e2a 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/ScopeBridge.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/ScopeBridge.kt @@ -33,4 +33,12 @@ object ScopeBridge { * Returns the number of bins written. Zero-copy: writes straight into JS memory. */ external fun nativeFillSpectrum(buffer: java.nio.ByteBuffer, capacityFloats: Int): Int + + /** + * Render thread. Fill `buffer` (a direct ByteBuffer over the JS Float32Array's + * memory) with render-ready, evenly spaced points from the latest triggered + * oscilloscope window. Returns the number of points written. Zero-copy: + * writes straight into JS memory. + */ + external fun nativeFillOscilloscope(buffer: java.nio.ByteBuffer, capacityFloats: Int): Int } diff --git a/modules/astra-scope/cpp/oscilloscope.cpp b/modules/astra-scope/cpp/oscilloscope.cpp index 7d2a158..da599e8 100644 --- a/modules/astra-scope/cpp/oscilloscope.cpp +++ b/modules/astra-scope/cpp/oscilloscope.cpp @@ -3,6 +3,14 @@ #include namespace Visualizer { +namespace { + +float safeFilterFrequency(float frequency, float sampleRate) { + const float nyquistSafe = std::max(20.0f, sampleRate * 0.45f); + return std::clamp(frequency, 20.0f, nyquistSafe); +} + +} // namespace Oscilloscope::Oscilloscope() : sampleRate_(48000.0f) @@ -29,6 +37,10 @@ Oscilloscope::Oscilloscope() // Initialize analysis and render buffers displayBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f); visualBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f); + pitchAnalysisBuffer_.resize(2048, 0.0f); + pitchWindowedBuffer_.resize(2048, 0.0f); + pitchMagnitudes_.resize(1024, 0.0f); + pitchFft_ = std::make_unique(2048); // Initialize display filters (high shelf + cascaded lowpass for steep rolloff) displayShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f); @@ -42,20 +54,23 @@ Oscilloscope::Oscilloscope() void Oscilloscope::setSampleRate(float sampleRate) { sampleRate_ = sampleRate; + const float shelfFrequency = safeFilterFrequency(400.0f, sampleRate_); + const float lowpassFrequency = safeFilterFrequency(18000.0f, sampleRate_); + // Redesign filter with new sample rate (10% bandwidth) float bandwidth = lastFilterPitch_ * 0.1f; bandpassFilter_.designBandpass(lastFilterPitch_, bandwidth, sampleRate_, 60.0f); // Update high shelf for new sample rate - pitchAnalysisShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f); + pitchAnalysisShelf_.setHighShelf(shelfFrequency, sampleRate_, -3.0f, 0.71f); // Update display filters - displayShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f); - displayLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f); - displayLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f); + displayShelf_.setHighShelf(shelfFrequency, sampleRate_, -3.0f, 0.71f); + displayLowpass1_.setLowpass(lowpassFrequency, sampleRate_, 0.707f); + displayLowpass2_.setLowpass(lowpassFrequency, sampleRate_, 0.707f); // Update pitch detection lowpass - pitchLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f); - pitchLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f); + pitchLowpass1_.setLowpass(lowpassFrequency, sampleRate_, 0.707f); + pitchLowpass2_.setLowpass(lowpassFrequency, sampleRate_, 0.707f); } void Oscilloscope::setPitchLock(bool enabled) { @@ -150,27 +165,26 @@ OscilloscopeResult Oscilloscope::process() { // Detect pitch from recent samples in circular buffer // Use RAW buffer for pitch detection (filtered buffer may attenuate the fundamental) // Use last 2048 samples for pitch detection - std::vector recentSamples(2048); for (size_t i = 0; i < 2048; i++) { size_t idx = (writePos_ + OSCILLOSCOPE_BUFFER_SIZE - 2048 + i) % OSCILLOSCOPE_BUFFER_SIZE; - recentSamples[i] = displayBuffer_[idx]; // Use RAW samples, not filtered + pitchAnalysisBuffer_[i] = displayBuffer_[idx]; // Use RAW samples, not filtered } // Apply high shelf filter to reduce HF interference with pitch detection pitchAnalysisShelf_.reset(); for (size_t i = 0; i < 2048; i++) { - recentSamples[i] = pitchAnalysisShelf_.process(recentSamples[i]); + pitchAnalysisBuffer_[i] = pitchAnalysisShelf_.process(pitchAnalysisBuffer_[i]); } // Apply cascaded lowpass for steep HF rejection pitchLowpass1_.reset(); pitchLowpass2_.reset(); for (size_t i = 0; i < 2048; i++) { - recentSamples[i] = pitchLowpass1_.process(recentSamples[i]); - recentSamples[i] = pitchLowpass2_.process(recentSamples[i]); + pitchAnalysisBuffer_[i] = pitchLowpass1_.process(pitchAnalysisBuffer_[i]); + pitchAnalysisBuffer_[i] = pitchLowpass2_.process(pitchAnalysisBuffer_[i]); } - float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f); + float newPitch = detectPitchFFTReused(pitchAnalysisBuffer_.data(), 2048, 40.0f, 1000.0f); if (newPitch > 0.0f) { pitchSamplesProcessed_++; @@ -263,44 +277,97 @@ void Oscilloscope::getSamples(float* output, size_t startPos, size_t count) cons // This preserves the high-precision trigger position from zero-crossing detection void Oscilloscope::getSamplesInterpolated(float* output, float startPos, size_t count) const { for (size_t i = 0; i < count; i++) { - float pos = startPos + static_cast(i); + output[i] = sampleInterpolated(startPos + static_cast(i)); + } +} - // Wrap position to buffer bounds - while (pos < 0) pos += OSCILLOSCOPE_BUFFER_SIZE; - while (pos >= OSCILLOSCOPE_BUFFER_SIZE) pos -= OSCILLOSCOPE_BUFFER_SIZE; +void Oscilloscope::getSamplesInterpolated(float* output, float startPos, size_t count, float step) const { + for (size_t i = 0; i < count; i++) { + output[i] = sampleInterpolated(startPos + static_cast(i) * step); + } +} - size_t idx = static_cast(pos) % OSCILLOSCOPE_BUFFER_SIZE; - float frac = pos - std::floor(pos); +float Oscilloscope::sampleInterpolated(float pos) const { + // Wrap position to buffer bounds + while (pos < 0) pos += OSCILLOSCOPE_BUFFER_SIZE; + while (pos >= OSCILLOSCOPE_BUFFER_SIZE) pos -= OSCILLOSCOPE_BUFFER_SIZE; - if (frac < 0.0001f) { - // No interpolation needed - exact sample position - output[i] = visualBuffer_[idx]; - } else { - // Cubic (Catmull-Rom) interpolation for smooth sub-sample rendering - // This eliminates pixel-level ghosting/jitter from truncated trigger positions - size_t i0 = (idx + OSCILLOSCOPE_BUFFER_SIZE - 1) % OSCILLOSCOPE_BUFFER_SIZE; - size_t i1 = idx; - size_t i2 = (idx + 1) % OSCILLOSCOPE_BUFFER_SIZE; - size_t i3 = (idx + 2) % OSCILLOSCOPE_BUFFER_SIZE; + size_t idx = static_cast(pos) % OSCILLOSCOPE_BUFFER_SIZE; + float frac = pos - std::floor(pos); - float y0 = visualBuffer_[i0]; - float y1 = visualBuffer_[i1]; - float y2 = visualBuffer_[i2]; - float y3 = visualBuffer_[i3]; + if (frac < 0.0001f) { + // No interpolation needed - exact sample position + return visualBuffer_[idx]; + } - // Catmull-Rom spline coefficients - float t = frac; - float t2 = t * t; - float t3 = t2 * t; + // Cubic (Catmull-Rom) interpolation for smooth sub-sample rendering. + size_t i0 = (idx + OSCILLOSCOPE_BUFFER_SIZE - 1) % OSCILLOSCOPE_BUFFER_SIZE; + size_t i1 = idx; + size_t i2 = (idx + 1) % OSCILLOSCOPE_BUFFER_SIZE; + size_t i3 = (idx + 2) % OSCILLOSCOPE_BUFFER_SIZE; - output[i] = 0.5f * ( - (2.0f * y1) + - (-y0 + y2) * t + - (2.0f * y0 - 5.0f * y1 + 4.0f * y2 - y3) * t2 + - (-y0 + 3.0f * y1 - 3.0f * y2 + y3) * t3 - ); + float y0 = visualBuffer_[i0]; + float y1 = visualBuffer_[i1]; + float y2 = visualBuffer_[i2]; + float y3 = visualBuffer_[i3]; + + float t = frac; + float t2 = t * t; + float t3 = t2 * t; + + return 0.5f * ( + (2.0f * y1) + + (-y0 + y2) * t + + (2.0f * y0 - 5.0f * y1 + 4.0f * y2 - y3) * t2 + + (-y0 + 3.0f * y1 - 3.0f * y2 + y3) * t3 + ); +} + +float Oscilloscope::detectPitchFFTReused(const float* data, size_t length, float minFreq, float maxFreq) { + const size_t fftSize = 2048; + if (length < fftSize || !pitchFft_) { + return 0.0f; + } + + for (size_t i = 0; i < fftSize; i++) { + float win = 0.5f * (1.0f - cosf(2.0f * static_cast(M_PI) * i / fftSize)); + pitchWindowedBuffer_[i] = data[i] * win; + } + + pitchFft_->forward(pitchWindowedBuffer_.data(), pitchMagnitudes_.data()); + + int minBin = std::max(1, static_cast(minFreq * fftSize / sampleRate_)); + int maxBin = std::min(static_cast(fftSize / 2 - 1), static_cast(maxFreq * fftSize / sampleRate_)); + if (minBin >= maxBin) { + return 0.0f; + } + + float peakMag = 0.0f; + int peakBin = minBin; + for (int i = minBin; i <= maxBin; i++) { + if (pitchMagnitudes_[i] > peakMag) { + peakMag = pitchMagnitudes_[i]; + peakBin = i; } } + + if (peakMag < 1e-6f) { + return 0.0f; + } + + if (peakBin > 0 && peakBin < static_cast(fftSize / 2) - 1) { + float y1 = pitchMagnitudes_[peakBin - 1]; + float y2 = pitchMagnitudes_[peakBin]; + float y3 = pitchMagnitudes_[peakBin + 1]; + float denom = y1 - 2.0f * y2 + y3; + if (std::abs(denom) > 1e-9f) { + float offset = 0.5f * (y1 - y3) / denom; + offset = std::clamp(offset, -0.5f, 0.5f); + return (static_cast(peakBin) + offset) * sampleRate_ / static_cast(fftSize); + } + } + + return static_cast(peakBin) * sampleRate_ / static_cast(fftSize); } void Oscilloscope::reset() { diff --git a/modules/astra-scope/cpp/oscilloscope.h b/modules/astra-scope/cpp/oscilloscope.h index 2d57acf..cc39fd8 100644 --- a/modules/astra-scope/cpp/oscilloscope.h +++ b/modules/astra-scope/cpp/oscilloscope.h @@ -1,6 +1,7 @@ #pragma once #include "dsp_utils.h" +#include #include #include @@ -41,6 +42,7 @@ public: // Get samples with sub-sample interpolation (preserves trigger precision) void getSamplesInterpolated(float* output, float startPos, size_t count) const; + void getSamplesInterpolated(float* output, float startPos, size_t count, float step) const; // Reset state void reset(); @@ -76,10 +78,16 @@ private: float lastTrigger_; float smoothedPitch_; int pitchSamplesProcessed_; // Track samples for adaptive smoothing + std::vector pitchAnalysisBuffer_; + std::vector pitchWindowedBuffer_; + std::vector pitchMagnitudes_; + std::unique_ptr pitchFft_; // Internal helpers void updateFiltered(); float findTriggerBackwards(size_t target, size_t range); + float sampleInterpolated(float pos) const; + float detectPitchFFTReused(const float* data, size_t length, float minFreq, float maxFreq); }; } // namespace Visualizer diff --git a/modules/astra-scope/cpp/scope_jni.cpp b/modules/astra-scope/cpp/scope_jni.cpp index 85d661c..5a7261f 100644 --- a/modules/astra-scope/cpp/scope_jni.cpp +++ b/modules/astra-scope/cpp/scope_jni.cpp @@ -57,4 +57,20 @@ Java_expo_modules_astrascope_ScopeBridge_nativeFillSpectrum( return static_cast(n); } +// Fills a direct ByteBuffer (over the JS Float32Array's memory) with render-ready +// points from the latest triggered oscilloscope window. Zero-copy. +JNIEXPORT jint JNICALL +Java_expo_modules_astrascope_ScopeBridge_nativeFillOscilloscope( + JNIEnv* env, jobject /*thiz*/, jobject buffer, jint capacityFloats) { + if (buffer == nullptr || capacityFloats <= 0) { + return 0; + } + auto* dst = static_cast(env->GetDirectBufferAddress(buffer)); + if (dst == nullptr) { + return 0; + } + const size_t n = driver().fillOscilloscope(dst, static_cast(capacityFloats)); + return static_cast(n); +} + } // extern "C" diff --git a/modules/astra-scope/cpp/scope_ring.h b/modules/astra-scope/cpp/scope_ring.h index 236ed85..d40cbed 100644 --- a/modules/astra-scope/cpp/scope_ring.h +++ b/modules/astra-scope/cpp/scope_ring.h @@ -18,9 +18,13 @@ // read only the most recent fftSize samples, so a slow consumer simply sees the // latest window (correct for a rolling spectrum). +#include "oscilloscope.h" #include "spectrum.h" +#include #include +#include +#include #include #include #include @@ -77,11 +81,14 @@ class ScopeDriver { const size_t fftSize = spectrum_.getFFTSize(); const size_t w = writePos_.load(std::memory_order_acquire); + const size_t sampleRate = sr > 0 ? static_cast(sr) : static_cast(48000); + const size_t delaySamples = scopeOutputDelaySamples(sampleRate); + const size_t readHead = w > delaySamples ? w - delaySamples : 0; const std::vector* mags; - if (w >= fftSize) { + if (readHead >= fftSize) { scratch_.resize(fftSize); - const size_t start = w - fftSize; + const size_t start = readHead - fftSize; for (size_t i = 0; i < fftSize; ++i) { scratch_[i] = ring_[(start + i) & kMask]; } @@ -96,19 +103,169 @@ class ScopeDriver { return n; } + // Render thread (single consumer). Unlike the spectrum (which snapshots the + // latest window), the oscilloscope needs CONTINUOUS samples for a stable + // pitch-locked trigger. We drain a bounded recent slice into its internal + // circular buffer, then return render-ready points from the triggered window. + size_t fillOscilloscope(float* out, size_t cap) { + if (out == nullptr || cap == 0) { + return 0; + } + + const int sr = pendingSampleRate_.load(std::memory_order_acquire); + if (sr != oscAppliedSampleRate_) { + osc_.setSampleRate(static_cast(sr)); + oscDisplaySamples_ = normalizedDisplaySamples(sr); + osc_.setDisplaySamples(static_cast(oscDisplaySamples_)); + oscAppliedSampleRate_ = sr; + oscLastDrainTime_ = {}; + oscDrainCarrySamples_ = 0.0; + } + + const size_t w = writePos_.load(std::memory_order_acquire); + + if (w < kOscWarmupSamples) { + return 0; + } + + const size_t sampleRate = sr > 0 ? static_cast(sr) : static_cast(48000); + const size_t outputDelaySamples = scopeOutputDelaySamples(sampleRate); + size_t available = w - oscReadPos_; + const size_t staleResetSamples = std::max(kSize, sampleRate / 4); + const auto now = std::chrono::steady_clock::now(); + + if (available > kSize || available >= staleResetSamples) { + osc_.reset(); + oscSamplesSeen_ = 0; + oscLastDrainTime_ = now; + oscDrainCarrySamples_ = 0.0; + const size_t retained = std::min({w, kSize, kOscRetainedBacklogSamples}); + oscReadPos_ = w - retained; + available = retained; + } else if (available > kOscRetainedBacklogSamples) { + oscReadPos_ = w - kOscRetainedBacklogSamples; + available = kOscRetainedBacklogSamples; + oscDrainCarrySamples_ = 0.0; + } + + const size_t drainable = available > outputDelaySamples ? available - outputDelaySamples : 0; + size_t drainBudget = oscDrainBudget(now, sampleRate, drainable); + const size_t warmup = std::max(kOscWarmupSamples, oscDisplaySamples_); + if (oscSamplesSeen_ < warmup) { + drainBudget = std::max(drainBudget, std::min(drainable, warmup - oscSamplesSeen_)); + } + + const size_t drainEnd = oscReadPos_ + std::min(drainable, drainBudget); + while (oscReadPos_ < drainEnd) { + const size_t idx = oscReadPos_ & kMask; + const size_t chunk = std::min(drainEnd - oscReadPos_, kSize - idx); + osc_.pushSamples(&ring_[idx], chunk); + oscReadPos_ += chunk; + oscSamplesSeen_ += chunk; + } + + if (oscSamplesSeen_ < warmup) { + return 0; + } + + const Visualizer::OscilloscopeResult r = osc_.process(); + if (r.samplesToShow <= 1) { + return 0; + } + + const size_t count = std::min(cap, static_cast(r.samplesToShow)); + if (count < 2) { + return 0; + } + + const float step = static_cast(r.samplesToShow - 1) / + static_cast(count - 1); + osc_.getSamplesInterpolated(out, r.triggerIndex, count, step); + return count; + } + size_t binCount() const { return spectrum_.getFFTSize() / 2; } - void reset() { spectrum_.reset(); } + void reset() { + spectrum_.reset(); + osc_.reset(); + oscReadPos_ = writePos_.load(std::memory_order_acquire); + oscSamplesSeen_ = 0; + oscLastDrainTime_ = {}; + oscDrainCarrySamples_ = 0.0; + } private: ScopeDriver() : spectrum_(kFftSize) { - spectrum_.setSmoothing(0.9f); + spectrum_.setSmoothing(0.92f); ring_.assign(kSize, 0.0f); } static constexpr size_t kFftSize = 2048; // -> 1024 dB bins - static constexpr size_t kSize = 8192; // ring capacity (power of two) + static constexpr size_t kSize = 16384; // ring capacity (power of two) static constexpr size_t kMask = kSize - 1; + static constexpr size_t kOscWarmupSamples = 4096; + static constexpr size_t kOscRetainedBacklogSamples = kSize; + static constexpr size_t kOscMinFrameDrainSamples = 128; + static constexpr size_t kOscMaxFrameDrainSamples = 2048; + static constexpr double kScopeOutputDelaySeconds = 0.12; + + static size_t normalizedDisplaySamples(int sampleRate) { + constexpr double base = 2048.0; + constexpr double rateMin = 44100.0; + constexpr double rateMax = 48000.0; + const double safeRate = sampleRate > 0 ? static_cast(sampleRate) : rateMax; + + double samples = base; + if (safeRate < rateMin) { + samples = base * (safeRate / rateMin); + } else if (safeRate > rateMax) { + samples = base * (safeRate / rateMax); + } + + return static_cast(std::clamp(std::round(samples), 64.0, 32767.0)); + } + + static size_t scopeOutputDelaySamples(size_t sampleRate) { + const double samples = static_cast(sampleRate) * kScopeOutputDelaySeconds; + return static_cast(std::clamp( + std::round(samples), + 0.0, + static_cast(kOscRetainedBacklogSamples / 2))); + } + + size_t oscDrainBudget( + std::chrono::steady_clock::time_point now, + size_t sampleRate, + size_t available) { + if (available == 0) { + oscLastDrainTime_ = now; + oscDrainCarrySamples_ = 0.0; + return 0; + } + + double elapsedSeconds = 1.0 / 60.0; + if (oscLastDrainTime_.time_since_epoch().count() != 0) { + elapsedSeconds = std::chrono::duration(now - oscLastDrainTime_).count(); + elapsedSeconds = std::clamp(elapsedSeconds, 0.0, 0.1); + } + oscLastDrainTime_ = now; + + double desired = elapsedSeconds * static_cast(sampleRate) + oscDrainCarrySamples_; + size_t budget = static_cast(std::floor(desired)); + oscDrainCarrySamples_ = desired - static_cast(budget); + + if (budget < kOscMinFrameDrainSamples) { + budget = std::min(kOscMinFrameDrainSamples, available); + oscDrainCarrySamples_ = 0.0; + } + + const size_t drain = std::min({available, budget, kOscMaxFrameDrainSamples}); + if (drain >= available) { + oscDrainCarrySamples_ = 0.0; + } + return drain; + } // Shared SPSC state. std::vector ring_; @@ -119,6 +276,15 @@ class ScopeDriver { std::vector scratch_; Visualizer::Spectrum spectrum_; int appliedSampleRate_{0}; + + Visualizer::Oscilloscope osc_; + size_t oscReadPos_{0}; + size_t oscSamplesSeen_{0}; + size_t oscDisplaySamples_{2048}; + std::chrono::steady_clock::time_point oscLastDrainTime_{}; + double oscDrainCarrySamples_{0.0}; + int oscAppliedSampleRate_{0}; + }; } // namespace astra diff --git a/modules/astra-scope/index.ts b/modules/astra-scope/index.ts index fb75da9..25c794e 100644 --- a/modules/astra-scope/index.ts +++ b/modules/astra-scope/index.ts @@ -7,6 +7,9 @@ export const SPECTRUM_BINS = 1024; export const SPECTRUM_DB_MIN = -100; export const SPECTRUM_DB_MAX = 0; +/** Render-ready oscilloscope point count requested by the mobile UI. */ +export const OSCILLOSCOPE_POINTS = 256; + declare class AstraScopeModuleType extends NativeModule { /** Gate the audio-thread PCM tap. Off when backgrounded/paused/reduced-motion. */ setActive(active: boolean): void; @@ -16,6 +19,12 @@ declare class AstraScopeModuleType extends NativeModule { * per render frame from the JS thread. */ getSpectrumFrame(out: Float32Array): number; + /** + * Fill `out` with render-ready, evenly spaced points from the latest + * pitch-locked oscilloscope window. Values are interpolated visual samples in + * ~[-1, 1]. Returns the number of points written (0 before warmup). + */ + getOscilloscopeFrame(out: Float32Array): number; } export const AstraScope = requireNativeModule('AstraScope'); diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index e845825..add2cf4 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -23,6 +23,7 @@ import { colors, radius, spacing } from '@/theme'; import { motion } from '@/theme/motion'; import { usePlayerStore } from '@/stores/playerStore'; import { usePlaylistStore } from '@/stores/playlistStore'; +import { useSettingsStore } from '@/stores/settingsStore'; import { cycleRepeat, seekTo, @@ -130,6 +131,7 @@ export default function NowPlayingScreen() { const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const [showScopeStage, setShowScopeStage] = useState(false); const [queueOpen, setQueueOpen] = useState(false); + const scopeMode = useSettingsStore((s) => s.scopeMode); const track = usePlayerStore((s) => s.currentTrack); const playbackState = usePlayerStore((s) => s.playbackState); const currentTime = usePlayerStore((s) => s.currentTime); @@ -259,9 +261,30 @@ export default function NowPlayingScreen() { height={layout.scopeHeight} interactive={false} showChrome={false} - mode="spectrum" + mode={scopeMode} edgeFade /> + {/* Nested Pressable: RN grants the responder to this + button, so a swap tap does not also collapse the + stage (the outer Pressable's onPress won't fire). */} + + useSettingsStore + .getState() + .setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum') + } + hitSlop={12} + style={styles.scopeSwap} + accessibilityRole="button" + accessibilityLabel={`Showing ${ + scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope' + }. Tap to switch.`} + > + + {scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'} + + + ) : ( s.duration); const scopeActive = useScopeActive(); - const values = useSpectrumCurve(CURVE_POINTS, scopeActive); const [pillWidth, setPillWidth] = useState(0); if (!track) return null; @@ -45,11 +43,16 @@ export function MiniPlayer() { {scopeActive && pillWidth > 0 && ( )} diff --git a/src/components/OscilloscopeWave.tsx b/src/components/OscilloscopeWave.tsx new file mode 100644 index 0000000..f29242c --- /dev/null +++ b/src/components/OscilloscopeWave.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { + PaintStyle, + Skia, + SkiaPictureView, + StrokeCap, + StrokeJoin, + type SkPicture, +} from '@shopify/react-native-skia'; +import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope'; +import { colors } from '@/theme'; + +interface OscilloscopeWaveProps { + active: boolean; + width: number; + height: number; + color?: string; + lineWidth?: number; + glow?: boolean; + edgeFade?: boolean; +} + +type SkiaViewApiShape = { + setJsiProperty: (nativeId: number, name: string, value: T) => void; + requestRedraw: (nativeId: number) => void; +}; + +const VISUAL_GAIN = 1.8; +const values = new Float32Array(OSCILLOSCOPE_POINTS); + +function skiaViewApi(): SkiaViewApiShape | null { + const globalWithSkia = globalThis as typeof globalThis & { SkiaViewApi?: SkiaViewApiShape }; + return globalWithSkia.SkiaViewApi ?? null; +} + +function withAlpha(hex: string, alpha: number): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +function makeStrokePaint(color: string, width: number, alpha = 1) { + const paint = Skia.Paint(); + paint.setAntiAlias(true); + paint.setColor(Skia.Color(alpha === 1 ? color : withAlpha(color, alpha))); + paint.setStrokeWidth(width); + paint.setStyle(PaintStyle.Stroke); + paint.setStrokeCap(StrokeCap.Round); + paint.setStrokeJoin(StrokeJoin.Round); + return paint; +} + +function buildPicture( + samples: Float32Array, + sampleCount: number, + width: number, + height: number, + color: string, + lineWidth: number, + glow: boolean +): SkPicture { + const recorder = Skia.PictureRecorder(); + const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height)); + const n = Math.min(sampleCount, samples.length); + + if (n >= 2 && width > 0 && height > 0) { + const path = Skia.Path.Make(); + const mid = height / 2; + const amp = mid - lineWidth; + const xAt = (i: number) => (i / (n - 1)) * width; + const yAt = (i: number) => { + let v = samples[i] * VISUAL_GAIN; + if (v < -1) v = -1; + else if (v > 1) v = 1; + return mid - v * amp; + }; + + path.moveTo(0, yAt(0)); + for (let i = 1; i < n; i++) { + path.lineTo(xAt(i), yAt(i)); + } + + if (glow) { + canvas.drawPath(path, makeStrokePaint(color, lineWidth * 3, 0.18)); + } + canvas.drawPath(path, makeStrokePaint(color, lineWidth)); + } + + return recorder.finishRecordingAsPicture(); +} + +/** + * Imperative oscilloscope renderer. This mirrors desktop/prism's hot path: + * a frame loop pulls native scope data and draws directly into a canvas-like + * surface instead of routing each frame through React reconciliation. + */ +export function OscilloscopeWave({ + active, + width, + height, + color = colors.accent, + lineWidth = 2, + glow = false, + edgeFade: _edgeFade = false, +}: OscilloscopeWaveProps) { + const viewRef = useRef(null); + const initialPicture = useMemo( + () => buildPicture(values, values.length, Math.max(1, width), Math.max(1, height), color, lineWidth, glow), + [color, glow, height, lineWidth, width] + ); + + useEffect(() => { + const view = viewRef.current; + const api = skiaViewApi(); + if (!view || !api || width <= 0 || height <= 0) return; + + let mounted = true; + let raf = 0; + + const draw = (sampleCount: number) => { + const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow); + api.setJsiProperty(view.nativeId, 'picture', picture); + api.requestRedraw(view.nativeId); + }; + + values.fill(0); + draw(values.length); + + const tick = () => { + if (!mounted) return; + raf = requestAnimationFrame(tick); + if (!active) return; + + const n = AstraScope.getOscilloscopeFrame(values); + if (n > 0) draw(n); + }; + + raf = requestAnimationFrame(tick); + return () => { + mounted = false; + cancelAnimationFrame(raf); + }; + }, [active, color, glow, height, lineWidth, width]); + + if (width <= 0 || height <= 0) return null; + return ; +} + +export default OscilloscopeWave; diff --git a/src/components/SpectrumCurve.tsx b/src/components/SpectrumCurve.tsx index 7e098b4..6da71fd 100644 --- a/src/components/SpectrumCurve.tsx +++ b/src/components/SpectrumCurve.tsx @@ -1,25 +1,62 @@ -import { useMemo } from 'react'; -import { Canvas, Group, LinearGradient, Path, Rect, Skia, vec } from '@shopify/react-native-skia'; +import { useEffect, useMemo, useRef } from 'react'; +import { + PaintStyle, + Skia, + SkiaPictureView, + StrokeCap, + StrokeJoin, + TileMode, + type SkPicture, +} from '@shopify/react-native-skia'; +import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope'; import { colors } from '@/theme'; interface SpectrumCurveProps { - /** Normalized magnitudes in [0,1], one per point (see useSpectrumCurve). */ - values: number[]; + /** Normalized magnitudes in [0,1] for static rendering. Live rendering ignores this. */ + values?: ArrayLike; width: number; height: number; - /** Hex line/fill color (e.g. theme accent). Defaults to the cyan accent. */ + /** Pull native spectrum frames while active, bypassing React per-frame state. */ + active?: boolean; + /** Number of render points when active. Defaults to one point per rendered pixel. */ + pointCount?: number; + /** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */ + frameMs?: number; + /** Native pull cadence. Defaults to frameMs; 0 advances analysis every display frame. */ + analysisFrameMs?: number; + dbMin?: number; + dbMax?: number; + tiltDbPerOctave?: number; color?: string; lineWidth?: number; - /** 0..1 multiplier on the gradient fill under the line. */ fillOpacity?: number; - /** Adds a soft wider stroke under the line for a glow. */ glow?: boolean; - /** Fades the left/right edges into the background so stage views do not end abruptly. */ edgeFade?: boolean; edgeFadeColor?: string; edgeFadeWidth?: number; } +type SkiaViewApiShape = { + setJsiProperty: (nativeId: number, name: string, value: T) => void; + requestRedraw: (nativeId: number) => void; +}; + +const DEFAULT_POINTS = 120; +const MINI_FRAME_MS = 32; +const DISPLAY_DB_MIN = -90; +const DISPLAY_DB_MAX = -10; +const SPECTRUM_SAMPLE_RATE = 48000; +const MIN_FREQUENCY = 20; +const MAX_FREQUENCY = 20000; +const TILT_DB_PER_OCT = 3.5; +const TILT_REFERENCE_HZ = 1000; +const spectrumBins = new Float32Array(SPECTRUM_BINS); + +function skiaViewApi(): SkiaViewApiShape | null { + const globalWithSkia = globalThis as typeof globalThis & { SkiaViewApi?: SkiaViewApiShape }; + return globalWithSkia.SkiaViewApi ?? null; +} + /** #rrggbb -> rgba() with the given alpha. */ function withAlpha(hex: string, alpha: number): string { const r = parseInt(hex.slice(1, 3), 16); @@ -28,12 +65,53 @@ function withAlpha(hex: string, alpha: number): string { return `rgba(${r}, ${g}, ${b}, ${alpha})`; } -/** - * Builds a smooth (quadratic-through-midpoints) path for the line, plus a copy - * closed to the baseline for the gradient fill. Same curve the desktop spectrum - * draws, ported to Skia. - */ -function buildPaths(values: number[], width: number, height: number, pad: number) { +function makeStrokePaint(color: string, width: number, alpha = 1) { + const paint = Skia.Paint(); + paint.setAntiAlias(true); + paint.setColor(Skia.Color(alpha === 1 ? color : withAlpha(color, alpha))); + paint.setStrokeWidth(width); + paint.setStyle(PaintStyle.Stroke); + paint.setStrokeCap(StrokeCap.Round); + paint.setStrokeJoin(StrokeJoin.Round); + return paint; +} + +function makeFillPaint(color: string, height: number, opacity: number) { + const paint = Skia.Paint(); + paint.setAntiAlias(true); + paint.setStyle(PaintStyle.Fill); + paint.setShader( + Skia.Shader.MakeLinearGradient( + { x: 0, y: 0 }, + { x: 0, y: height }, + [ + Skia.Color(withAlpha(color, 0.38 * opacity)), + Skia.Color(withAlpha(color, 0.08 * opacity)), + Skia.Color(withAlpha(color, 0)), + ], + null, + TileMode.Clamp + ) + ); + return paint; +} + +function makeFadePaint(color: string, startAlpha: number, endAlpha: number, x0: number, x1: number) { + const paint = Skia.Paint(); + paint.setStyle(PaintStyle.Fill); + paint.setShader( + Skia.Shader.MakeLinearGradient( + { x: x0, y: 0 }, + { x: x1, y: 0 }, + [Skia.Color(withAlpha(color, startAlpha)), Skia.Color(withAlpha(color, endAlpha))], + null, + TileMode.Clamp + ) + ); + return paint; +} + +function buildPaths(values: ArrayLike, width: number, height: number, pad: number) { const line = Skia.Path.Make(); const n = values.length; if (n < 2 || width <= 0 || height <= 0) return { line, fill: line.copy() }; @@ -61,14 +139,161 @@ function buildPaths(values: number[], width: number, height: number, pad: number return { line, fill }; } +function buildPicture( + values: ArrayLike, + width: number, + height: number, + color: string, + lineWidth: number, + fillOpacity: number, + glow: boolean, + edgeFade: boolean, + edgeFadeColor: string, + edgeFadeWidth: number +): SkPicture { + const recorder = Skia.PictureRecorder(); + const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height)); + const { line, fill } = buildPaths(values, width, height, lineWidth); + + if (values.length >= 2 && width > 0 && height > 0) { + canvas.drawPath(fill, makeFillPaint(color, height, fillOpacity)); + if (glow) { + canvas.drawPath(line, makeStrokePaint(color, lineWidth * 3, 0.18)); + } + canvas.drawPath(line, makeStrokePaint(color, lineWidth)); + } + + if (edgeFade && width > 0 && height > 0 && edgeFadeWidth > 0) { + const fadeWidth = Math.min(edgeFadeWidth, width * 0.5); + canvas.drawRect( + Skia.XYWHRect(0, 0, fadeWidth, height), + makeFadePaint(edgeFadeColor, 1, 0, 0, fadeWidth) + ); + canvas.drawRect( + Skia.XYWHRect(width - fadeWidth, 0, fadeWidth, height), + makeFadePaint(edgeFadeColor, 0, 1, width - fadeWidth, width) + ); + } + + return recorder.finishRecordingAsPicture(); +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function interpolatedValue(data: Float32Array, index: number): number { + const i0 = Math.max(0, Math.min(data.length - 1, Math.floor(index))); + const i1 = Math.min(i0 + 1, data.length - 1); + return lerp(data[i0], data[i1], index - i0); +} + +function frequencyAtPosition(t: number, minFrequency: number, maxFrequency: number): number { + const logMin = Math.log10(minFrequency); + const logMax = Math.log10(maxFrequency); + return 10 ** (logMin + t * (logMax - logMin)); +} + +function peakInRange(data: Float32Array, startIndex: number, endIndex: number, binWidth: number) { + const clampedStart = Math.max(0, Math.min(data.length - 1, startIndex)); + const clampedEnd = Math.max(0, Math.min(data.length - 1, endIndex)); + const lo = Math.floor(Math.min(clampedStart, clampedEnd)); + const hi = Math.ceil(Math.max(clampedStart, clampedEnd)); + + if (hi <= lo) { + return { + rawDb: interpolatedValue(data, clampedStart), + frequencyHz: Math.max(0, clampedStart * binWidth), + }; + } + + let peakBin = lo; + let peakDb = Number.NEGATIVE_INFINITY; + for (let i = lo; i <= hi; i++) { + if (data[i] > peakDb) { + peakDb = data[i]; + peakBin = i; + } + } + + if (peakBin > 0 && peakBin < data.length - 1) { + const y1 = data[peakBin - 1]; + const y2 = data[peakBin]; + const y3 = data[peakBin + 1]; + const denominator = y1 - 2 * y2 + y3; + if (Math.abs(denominator) > 1e-9) { + const offset = Math.max(-0.5, Math.min(0.5, 0.5 * (y1 - y3) / denominator)); + return { + rawDb: y2 - 0.25 * (y1 - y3) * offset, + frequencyHz: Math.max(0, (peakBin + offset) * binWidth), + }; + } + } + + return { + rawDb: peakDb, + frequencyHz: Math.max(0, peakBin * binWidth), + }; +} + +interface SpectrumPointOptions { + dbMin: number; + dbMax: number; + tiltDbPerOctave: number; +} + +function applyTilt(db: number, frequency: number, tiltDbPerOctave: number): number { + const safeFreq = Math.max(1, frequency); + return db + tiltDbPerOctave * Math.log2(safeFreq / TILT_REFERENCE_HZ); +} + +function writeSpectrumPoints(rawBins: Float32Array, out: Float32Array, options: SpectrumPointOptions) { + const pointCount = out.length; + const bufferLength = rawBins.length; + const nyquist = SPECTRUM_SAMPLE_RATE / 2; + const minFrequency = Math.max(1, Math.min(MIN_FREQUENCY, nyquist)); + const maxFrequency = Math.max(minFrequency + 1, Math.min(MAX_FREQUENCY, nyquist)); + const binWidth = nyquist / bufferLength; + const dbRange = Math.max(1, options.dbMax - options.dbMin); + + for (let p = 0; p < pointCount; p++) { + const t0 = p / (pointCount - 1); + const t1 = Math.min(1, (p + 1) / (pointCount - 1)); + const frequency0 = frequencyAtPosition(t0, minFrequency, maxFrequency); + const frequency1 = frequencyAtPosition(t1, minFrequency, maxFrequency); + const centerFrequency = (frequency0 + frequency1) * 0.5; + const bin0 = frequency0 / binWidth; + const bin1 = frequency1 / binWidth; + const centerBin = (bin0 + bin1) * 0.5; + const binSpan = Math.abs(bin1 - bin0); + const rawDb = + binSpan <= 1 + ? interpolatedValue(rawBins, Math.min(centerBin, bufferLength - 1)) + : peakInRange(rawBins, bin0, bin1, binWidth).rawDb; + const db = applyTilt(rawDb, centerFrequency, options.tiltDbPerOctave); + + let norm = (db - options.dbMin) / dbRange; + if (norm < 0) norm = 0; + else if (norm > 1) norm = 1; + out[p] = norm; + } +} + /** - * Filled-line spectrum (the desktop "CURVE" look): a smooth line over a vertical - * gradient fill. Source-agnostic — give it normalized values and a size. + * Filled-line spectrum. When `active` is true this mirrors the oscilloscope hot + * path: a frame loop pulls native data and updates the Skia view imperatively. */ export function SpectrumCurve({ values, width, height, + active = false, + pointCount, + frameMs = MINI_FRAME_MS, + analysisFrameMs, + dbMin = DISPLAY_DB_MIN, + dbMax = DISPLAY_DB_MAX, + tiltDbPerOctave = TILT_DB_PER_OCT, color = colors.accent, lineWidth = 2, fillOpacity = 1, @@ -77,63 +302,110 @@ export function SpectrumCurve({ edgeFadeColor = colors.bgPrimary, edgeFadeWidth = 28, }: SpectrumCurveProps) { - const pad = lineWidth; - const { line, fill } = useMemo( - () => buildPaths(values, width, height, pad), - [values, width, height, pad] + const viewRef = useRef(null); + const activePointCount = Math.max(2, Math.floor(width)); + const resolvedPointCount = pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS); + const staticValues = useMemo( + () => values ?? new Float32Array(resolvedPointCount), + [resolvedPointCount, values] ); + const initialPicture = useMemo( + () => + buildPicture( + staticValues, + Math.max(1, width), + Math.max(1, height), + color, + lineWidth, + fillOpacity, + glow, + edgeFade, + edgeFadeColor, + edgeFadeWidth + ), + [color, edgeFade, edgeFadeColor, edgeFadeWidth, fillOpacity, glow, height, lineWidth, staticValues, width] + ); + + useEffect(() => { + if (!active) return; + const view = viewRef.current; + const api = skiaViewApi(); + if (!view || !api || width <= 0 || height <= 0 || resolvedPointCount < 2) return; + + let mounted = true; + let raf = 0; + let lastAnalysis = 0; + let lastDraw = 0; + let hasNewFrame = false; + const drawThreshold = frameMs > 0 ? Math.max(0, frameMs - 0.5) : 0; + const analysisMs = analysisFrameMs ?? frameMs; + const analysisThreshold = analysisMs > 0 ? Math.max(0, analysisMs - 0.5) : 0; + const renderValues = new Float32Array(resolvedPointCount); + const pointOptions = { dbMin, dbMax, tiltDbPerOctave }; + + const draw = () => { + const picture = buildPicture( + renderValues, + width, + height, + color, + lineWidth, + fillOpacity, + glow, + edgeFade, + edgeFadeColor, + edgeFadeWidth + ); + api.setJsiProperty(view.nativeId, 'picture', picture); + api.requestRedraw(view.nativeId); + }; + + renderValues.fill(0); + draw(); + + const tick = (t: number) => { + if (!mounted) return; + raf = requestAnimationFrame(tick); + if (analysisThreshold <= 0 || t - lastAnalysis >= analysisThreshold) { + lastAnalysis = t; + if (AstraScope.getSpectrumFrame(spectrumBins) > 0) { + writeSpectrumPoints(spectrumBins, renderValues, pointOptions); + hasNewFrame = true; + } + } + + if (!hasNewFrame || (drawThreshold > 0 && t - lastDraw < drawThreshold)) return; + lastDraw = t; + hasNewFrame = false; + draw(); + }; + + raf = requestAnimationFrame(tick); + return () => { + mounted = false; + cancelAnimationFrame(raf); + }; + }, [ + active, + analysisFrameMs, + color, + dbMax, + dbMin, + edgeFade, + edgeFadeColor, + edgeFadeWidth, + fillOpacity, + frameMs, + glow, + height, + lineWidth, + resolvedPointCount, + tiltDbPerOctave, + width, + ]); if (width <= 0 || height <= 0) return null; - - return ( - - - - - - - {glow && ( - - )} - - {edgeFade && ( - <> - - - - - - - - )} - - ); + return ; } export default SpectrumCurve; diff --git a/src/components/Visualizer.tsx b/src/components/Visualizer.tsx index f1fe13a..3bd1284 100644 --- a/src/components/Visualizer.tsx +++ b/src/components/Visualizer.tsx @@ -3,12 +3,12 @@ import { Pressable, StyleSheet, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Text } from './Text'; import { SpectrumCurve } from './SpectrumCurve'; +import { OscilloscopeWave } from './OscilloscopeWave'; import { colors, spacing } from '@/theme'; import { useScopeActive } from '@/scope/scopeStore'; -import { useSpectrumCurve } from '@/scope/useSpectrumCurve'; const CANVAS_HEIGHT = 96; -const POINTS = 120; +const STAGE_FRAME_MS = 0; // display-sync type Mode = 'spectrum' | 'scope'; @@ -38,7 +38,7 @@ export function Visualizer({ const mode = controlledMode ?? uncontrolledMode; const scopeActive = useScopeActive(); const spectrumActive = scopeActive && mode === 'spectrum'; - const values = useSpectrumCurve(POINTS, spectrumActive); + const scopeWaveActive = scopeActive && mode === 'scope'; const toggle = () => { if (controlledMode) return; @@ -58,14 +58,22 @@ export function Visualizer({ {mode === 'spectrum' ? ( - + ) : ( - - - - OSCILLOSCOPE · COMING SOON - - + )} @@ -109,17 +117,6 @@ const styles = StyleSheet.create({ letterSpacing: 1.5, fontSize: 10, }, - placeholder: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs, - }, - placeholderText: { - color: colors.textTertiary, - letterSpacing: 1.5, - fontSize: 10, - }, }); export default Visualizer; diff --git a/src/scope/useSpectrumCurve.ts b/src/scope/useSpectrumCurve.ts deleted file mode 100644 index 9e0932b..0000000 --- a/src/scope/useSpectrumCurve.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope'; - -const FRAME_MS = 32; // ~30fps — ambient, battery-friendly - -// Display window (dB). Tighter than the raw [-100,0] capture range so music -// fills the curve with punch instead of hugging the floor. -const DISPLAY_DB_MIN = -88; -const DISPLAY_DB_MAX = -16; -const DB_RANGE = DISPLAY_DB_MAX - DISPLAY_DB_MIN; - -// Map points across a log-frequency (geometric bin) axis like the desktop -// SpectrumAnalyzer, so the low end isn't squashed. Skip DC/rumble at the bottom. -const BIN_LOW = 2; -const BIN_HIGH = SPECTRUM_BINS - 1; -// Gentle upward tilt (dB/octave) so the curve reads as a shape, not a downward -// ramp dominated by bass — same idea as the desktop's spectrum tilt. -const TILT_DB_PER_OCT = 2; - -// Temporal smoothing: rise instantly, fall smoothly, for a fluid line. -const RELEASE = 0.72; - -// One reused buffer across all consumers: getSpectrumFrame fills it in place and -// we read it out synchronously on the JS thread, so a module-level buffer is safe. -const buffer = new Float32Array(SPECTRUM_BINS); - -/** - * Pulls the latest spectrum from the native tap on a JS-thread rAF loop (while - * `active`) and returns `pointCount` magnitudes in [0,1] sampled on a - * log-frequency axis, smoothed over time. Feeds the filled-line {@link - * SpectrumCurve}. Returns all-zero (flat) points when inactive — no loop, no - * setState — so callers render a clean baseline. - */ -export function useSpectrumCurve(pointCount: number, active: boolean): number[] { - const [values, setValues] = useState(() => new Array(pointCount).fill(0)); - const zeros = useMemo(() => new Array(pointCount).fill(0), [pointCount]); - - useEffect(() => { - if (!active) return; // inactive: no loop, no setState; caller gets `zeros` - let mounted = true; - let raf = 0; - let last = 0; - - const smoothed = new Float32Array(pointCount); - const logLow = Math.log(BIN_LOW); - const logHigh = Math.log(BIN_HIGH); - const binAt = (t: number) => Math.exp(logLow + t * (logHigh - logLow)); - const refBin = binAt(0.5); // tilt pivot (midband) - - const tick = (t: number) => { - if (!mounted) return; - raf = requestAnimationFrame(tick); - if (t - last < FRAME_MS) return; - last = t; - if (AstraScope.getSpectrumFrame(buffer) <= 0) return; - - const out = new Array(pointCount); - for (let p = 0; p < pointCount; p++) { - const b0 = binAt(p / pointCount); - const b1 = binAt((p + 1) / pointCount); - const lo = Math.max(BIN_LOW, Math.floor(b0)); - const hi = Math.min(BIN_HIGH, Math.max(lo, Math.ceil(b1))); - - // Peak (loudest bin) across the band — punchier than an average. - let db = -200; - for (let i = lo; i <= hi; i++) if (buffer[i] > db) db = buffer[i]; - - const octaves = Math.log2(Math.max(1, (b0 + b1) * 0.5) / refBin); - db += TILT_DB_PER_OCT * octaves; - - let norm = (db - DISPLAY_DB_MIN) / DB_RANGE; - if (norm < 0) norm = 0; - else if (norm > 1) norm = 1; - - const prev = smoothed[p]; - const next = norm >= prev ? norm : prev * RELEASE + norm * (1 - RELEASE); - smoothed[p] = next; - out[p] = next; - } - setValues(out); - }; - - raf = requestAnimationFrame(tick); - return () => { - mounted = false; - cancelAnimationFrame(raf); - }; - }, [active, pointCount]); - - return active ? values : zeros; -} diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index b4ba38f..278aa8e 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -9,27 +9,45 @@ import type { ArtistGroupingMode } from '@/library/artistGrouping'; * subscribes here to recompute the artist list when the grouping mode changes. */ const ARTIST_GROUPING_KEY = 'artist_grouping_mode'; +const SCOPE_MODE_KEY = 'scope_mode'; + +/** Which visualizer the now-playing scope stage shows. */ +export type ScopeMode = 'spectrum' | 'scope'; function parseGroupingMode(value: string | null): ArtistGroupingMode { return value === 'fileTags' ? 'fileTags' : 'astra'; } +function parseScopeMode(value: string | null): ScopeMode { + return value === 'scope' ? 'scope' : 'spectrum'; +} + interface SettingsStore { artistGroupingMode: ArtistGroupingMode; + scopeMode: ScopeMode; loaded: boolean; load: () => Promise; setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise; + setScopeMode: (mode: ScopeMode) => Promise; } export const useSettingsStore = create((set, get) => ({ artistGroupingMode: 'astra', + scopeMode: 'spectrum', loaded: false, load: async () => { if (get().loaded) return; const db = await openLibraryDb(); - const stored = await getSetting(db, ARTIST_GROUPING_KEY); - set({ artistGroupingMode: parseGroupingMode(stored), loaded: true }); + const [grouping, scope] = await Promise.all([ + getSetting(db, ARTIST_GROUPING_KEY), + getSetting(db, SCOPE_MODE_KEY), + ]); + set({ + artistGroupingMode: parseGroupingMode(grouping), + scopeMode: parseScopeMode(scope), + loaded: true, + }); }, setArtistGroupingMode: async (mode) => { @@ -38,4 +56,11 @@ export const useSettingsStore = create((set, get) => ({ const db = await openLibraryDb(); await setSetting(db, ARTIST_GROUPING_KEY, mode); }, + + setScopeMode: async (mode) => { + if (get().scopeMode === mode) return; + set({ scopeMode: mode }); + const db = await openLibraryDb(); + await setSetting(db, SCOPE_MODE_KEY, mode); + }, }));