mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
performance improvements + oscilloscope
This commit is contained in:
@@ -24,5 +24,10 @@ class AstraScopeModule : Module() {
|
|||||||
Function("getSpectrumFrame") { out: Float32Array ->
|
Function("getSpectrumFrame") { out: Float32Array ->
|
||||||
ScopeBridge.nativeFillSpectrum(out.toDirectBuffer(), out.length)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,12 @@ object ScopeBridge {
|
|||||||
* Returns the number of bins written. Zero-copy: writes straight into JS memory.
|
* Returns the number of bins written. Zero-copy: writes straight into JS memory.
|
||||||
*/
|
*/
|
||||||
external fun nativeFillSpectrum(buffer: java.nio.ByteBuffer, capacityFloats: Int): Int
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
namespace Visualizer {
|
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()
|
Oscilloscope::Oscilloscope()
|
||||||
: sampleRate_(48000.0f)
|
: sampleRate_(48000.0f)
|
||||||
@@ -29,6 +37,10 @@ Oscilloscope::Oscilloscope()
|
|||||||
// Initialize analysis and render buffers
|
// Initialize analysis and render buffers
|
||||||
displayBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
|
displayBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
|
||||||
visualBuffer_.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<DSP::FFT>(2048);
|
||||||
|
|
||||||
// Initialize display filters (high shelf + cascaded lowpass for steep rolloff)
|
// Initialize display filters (high shelf + cascaded lowpass for steep rolloff)
|
||||||
displayShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f);
|
displayShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f);
|
||||||
@@ -42,20 +54,23 @@ Oscilloscope::Oscilloscope()
|
|||||||
|
|
||||||
void Oscilloscope::setSampleRate(float sampleRate) {
|
void Oscilloscope::setSampleRate(float sampleRate) {
|
||||||
sampleRate_ = 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)
|
// Redesign filter with new sample rate (10% bandwidth)
|
||||||
float bandwidth = lastFilterPitch_ * 0.1f;
|
float bandwidth = lastFilterPitch_ * 0.1f;
|
||||||
bandpassFilter_.designBandpass(lastFilterPitch_, bandwidth, sampleRate_, 60.0f);
|
bandpassFilter_.designBandpass(lastFilterPitch_, bandwidth, sampleRate_, 60.0f);
|
||||||
// Update high shelf for new sample rate
|
// 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
|
// Update display filters
|
||||||
displayShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f);
|
displayShelf_.setHighShelf(shelfFrequency, sampleRate_, -3.0f, 0.71f);
|
||||||
displayLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
displayLowpass1_.setLowpass(lowpassFrequency, sampleRate_, 0.707f);
|
||||||
displayLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
displayLowpass2_.setLowpass(lowpassFrequency, sampleRate_, 0.707f);
|
||||||
|
|
||||||
// Update pitch detection lowpass
|
// Update pitch detection lowpass
|
||||||
pitchLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
pitchLowpass1_.setLowpass(lowpassFrequency, sampleRate_, 0.707f);
|
||||||
pitchLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
pitchLowpass2_.setLowpass(lowpassFrequency, sampleRate_, 0.707f);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Oscilloscope::setPitchLock(bool enabled) {
|
void Oscilloscope::setPitchLock(bool enabled) {
|
||||||
@@ -150,27 +165,26 @@ OscilloscopeResult Oscilloscope::process() {
|
|||||||
// Detect pitch from recent samples in circular buffer
|
// Detect pitch from recent samples in circular buffer
|
||||||
// Use RAW buffer for pitch detection (filtered buffer may attenuate the fundamental)
|
// Use RAW buffer for pitch detection (filtered buffer may attenuate the fundamental)
|
||||||
// Use last 2048 samples for pitch detection
|
// Use last 2048 samples for pitch detection
|
||||||
std::vector<float> recentSamples(2048);
|
|
||||||
for (size_t i = 0; i < 2048; i++) {
|
for (size_t i = 0; i < 2048; i++) {
|
||||||
size_t idx = (writePos_ + OSCILLOSCOPE_BUFFER_SIZE - 2048 + i) % OSCILLOSCOPE_BUFFER_SIZE;
|
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
|
// Apply high shelf filter to reduce HF interference with pitch detection
|
||||||
pitchAnalysisShelf_.reset();
|
pitchAnalysisShelf_.reset();
|
||||||
for (size_t i = 0; i < 2048; i++) {
|
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
|
// Apply cascaded lowpass for steep HF rejection
|
||||||
pitchLowpass1_.reset();
|
pitchLowpass1_.reset();
|
||||||
pitchLowpass2_.reset();
|
pitchLowpass2_.reset();
|
||||||
for (size_t i = 0; i < 2048; i++) {
|
for (size_t i = 0; i < 2048; i++) {
|
||||||
recentSamples[i] = pitchLowpass1_.process(recentSamples[i]);
|
pitchAnalysisBuffer_[i] = pitchLowpass1_.process(pitchAnalysisBuffer_[i]);
|
||||||
recentSamples[i] = pitchLowpass2_.process(recentSamples[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) {
|
if (newPitch > 0.0f) {
|
||||||
pitchSamplesProcessed_++;
|
pitchSamplesProcessed_++;
|
||||||
|
|
||||||
@@ -263,8 +277,17 @@ void Oscilloscope::getSamples(float* output, size_t startPos, size_t count) cons
|
|||||||
// This preserves the high-precision trigger position from zero-crossing detection
|
// This preserves the high-precision trigger position from zero-crossing detection
|
||||||
void Oscilloscope::getSamplesInterpolated(float* output, float startPos, size_t count) const {
|
void Oscilloscope::getSamplesInterpolated(float* output, float startPos, size_t count) const {
|
||||||
for (size_t i = 0; i < count; i++) {
|
for (size_t i = 0; i < count; i++) {
|
||||||
float pos = startPos + static_cast<float>(i);
|
output[i] = sampleInterpolated(startPos + static_cast<float>(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<float>(i) * step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float Oscilloscope::sampleInterpolated(float pos) const {
|
||||||
// Wrap position to buffer bounds
|
// Wrap position to buffer bounds
|
||||||
while (pos < 0) pos += OSCILLOSCOPE_BUFFER_SIZE;
|
while (pos < 0) pos += OSCILLOSCOPE_BUFFER_SIZE;
|
||||||
while (pos >= OSCILLOSCOPE_BUFFER_SIZE) pos -= OSCILLOSCOPE_BUFFER_SIZE;
|
while (pos >= OSCILLOSCOPE_BUFFER_SIZE) pos -= OSCILLOSCOPE_BUFFER_SIZE;
|
||||||
@@ -274,10 +297,10 @@ void Oscilloscope::getSamplesInterpolated(float* output, float startPos, size_t
|
|||||||
|
|
||||||
if (frac < 0.0001f) {
|
if (frac < 0.0001f) {
|
||||||
// No interpolation needed - exact sample position
|
// No interpolation needed - exact sample position
|
||||||
output[i] = visualBuffer_[idx];
|
return visualBuffer_[idx];
|
||||||
} else {
|
}
|
||||||
// Cubic (Catmull-Rom) interpolation for smooth sub-sample rendering
|
|
||||||
// This eliminates pixel-level ghosting/jitter from truncated trigger positions
|
// Cubic (Catmull-Rom) interpolation for smooth sub-sample rendering.
|
||||||
size_t i0 = (idx + OSCILLOSCOPE_BUFFER_SIZE - 1) % OSCILLOSCOPE_BUFFER_SIZE;
|
size_t i0 = (idx + OSCILLOSCOPE_BUFFER_SIZE - 1) % OSCILLOSCOPE_BUFFER_SIZE;
|
||||||
size_t i1 = idx;
|
size_t i1 = idx;
|
||||||
size_t i2 = (idx + 1) % OSCILLOSCOPE_BUFFER_SIZE;
|
size_t i2 = (idx + 1) % OSCILLOSCOPE_BUFFER_SIZE;
|
||||||
@@ -288,19 +311,63 @@ void Oscilloscope::getSamplesInterpolated(float* output, float startPos, size_t
|
|||||||
float y2 = visualBuffer_[i2];
|
float y2 = visualBuffer_[i2];
|
||||||
float y3 = visualBuffer_[i3];
|
float y3 = visualBuffer_[i3];
|
||||||
|
|
||||||
// Catmull-Rom spline coefficients
|
|
||||||
float t = frac;
|
float t = frac;
|
||||||
float t2 = t * t;
|
float t2 = t * t;
|
||||||
float t3 = t2 * t;
|
float t3 = t2 * t;
|
||||||
|
|
||||||
output[i] = 0.5f * (
|
return 0.5f * (
|
||||||
(2.0f * y1) +
|
(2.0f * y1) +
|
||||||
(-y0 + y2) * t +
|
(-y0 + y2) * t +
|
||||||
(2.0f * y0 - 5.0f * y1 + 4.0f * y2 - y3) * t2 +
|
(2.0f * y0 - 5.0f * y1 + 4.0f * y2 - y3) * t2 +
|
||||||
(-y0 + 3.0f * y1 - 3.0f * y2 + y3) * t3
|
(-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<float>(M_PI) * i / fftSize));
|
||||||
|
pitchWindowedBuffer_[i] = data[i] * win;
|
||||||
|
}
|
||||||
|
|
||||||
|
pitchFft_->forward(pitchWindowedBuffer_.data(), pitchMagnitudes_.data());
|
||||||
|
|
||||||
|
int minBin = std::max(1, static_cast<int>(minFreq * fftSize / sampleRate_));
|
||||||
|
int maxBin = std::min(static_cast<int>(fftSize / 2 - 1), static_cast<int>(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<int>(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<float>(peakBin) + offset) * sampleRate_ / static_cast<float>(fftSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return static_cast<float>(peakBin) * sampleRate_ / static_cast<float>(fftSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Oscilloscope::reset() {
|
void Oscilloscope::reset() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "dsp_utils.h"
|
#include "dsp_utils.h"
|
||||||
|
#include <memory>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ public:
|
|||||||
|
|
||||||
// Get samples with sub-sample interpolation (preserves trigger precision)
|
// 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) const;
|
||||||
|
void getSamplesInterpolated(float* output, float startPos, size_t count, float step) const;
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
void reset();
|
void reset();
|
||||||
@@ -76,10 +78,16 @@ private:
|
|||||||
float lastTrigger_;
|
float lastTrigger_;
|
||||||
float smoothedPitch_;
|
float smoothedPitch_;
|
||||||
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
|
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
|
||||||
|
std::vector<float> pitchAnalysisBuffer_;
|
||||||
|
std::vector<float> pitchWindowedBuffer_;
|
||||||
|
std::vector<float> pitchMagnitudes_;
|
||||||
|
std::unique_ptr<DSP::FFT> pitchFft_;
|
||||||
|
|
||||||
// Internal helpers
|
// Internal helpers
|
||||||
void updateFiltered();
|
void updateFiltered();
|
||||||
float findTriggerBackwards(size_t target, size_t range);
|
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
|
} // namespace Visualizer
|
||||||
|
|||||||
@@ -57,4 +57,20 @@ Java_expo_modules_astrascope_ScopeBridge_nativeFillSpectrum(
|
|||||||
return static_cast<jint>(n);
|
return static_cast<jint>(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<float*>(env->GetDirectBufferAddress(buffer));
|
||||||
|
if (dst == nullptr) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const size_t n = driver().fillOscilloscope(dst, static_cast<size_t>(capacityFloats));
|
||||||
|
return static_cast<jint>(n);
|
||||||
|
}
|
||||||
|
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
|
|||||||
@@ -18,9 +18,13 @@
|
|||||||
// read only the most recent fftSize samples, so a slow consumer simply sees the
|
// read only the most recent fftSize samples, so a slow consumer simply sees the
|
||||||
// latest window (correct for a rolling spectrum).
|
// latest window (correct for a rolling spectrum).
|
||||||
|
|
||||||
|
#include "oscilloscope.h"
|
||||||
#include "spectrum.h"
|
#include "spectrum.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -77,11 +81,14 @@ class ScopeDriver {
|
|||||||
|
|
||||||
const size_t fftSize = spectrum_.getFFTSize();
|
const size_t fftSize = spectrum_.getFFTSize();
|
||||||
const size_t w = writePos_.load(std::memory_order_acquire);
|
const size_t w = writePos_.load(std::memory_order_acquire);
|
||||||
|
const size_t sampleRate = sr > 0 ? static_cast<size_t>(sr) : static_cast<size_t>(48000);
|
||||||
|
const size_t delaySamples = scopeOutputDelaySamples(sampleRate);
|
||||||
|
const size_t readHead = w > delaySamples ? w - delaySamples : 0;
|
||||||
|
|
||||||
const std::vector<float>* mags;
|
const std::vector<float>* mags;
|
||||||
if (w >= fftSize) {
|
if (readHead >= fftSize) {
|
||||||
scratch_.resize(fftSize);
|
scratch_.resize(fftSize);
|
||||||
const size_t start = w - fftSize;
|
const size_t start = readHead - fftSize;
|
||||||
for (size_t i = 0; i < fftSize; ++i) {
|
for (size_t i = 0; i < fftSize; ++i) {
|
||||||
scratch_[i] = ring_[(start + i) & kMask];
|
scratch_[i] = ring_[(start + i) & kMask];
|
||||||
}
|
}
|
||||||
@@ -96,19 +103,169 @@ class ScopeDriver {
|
|||||||
return n;
|
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<float>(sr));
|
||||||
|
oscDisplaySamples_ = normalizedDisplaySamples(sr);
|
||||||
|
osc_.setDisplaySamples(static_cast<int>(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<size_t>(sr) : static_cast<size_t>(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<size_t>(r.samplesToShow));
|
||||||
|
if (count < 2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float step = static_cast<float>(r.samplesToShow - 1) /
|
||||||
|
static_cast<float>(count - 1);
|
||||||
|
osc_.getSamplesInterpolated(out, r.triggerIndex, count, step);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
size_t binCount() const { return spectrum_.getFFTSize() / 2; }
|
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:
|
private:
|
||||||
ScopeDriver() : spectrum_(kFftSize) {
|
ScopeDriver() : spectrum_(kFftSize) {
|
||||||
spectrum_.setSmoothing(0.9f);
|
spectrum_.setSmoothing(0.92f);
|
||||||
ring_.assign(kSize, 0.0f);
|
ring_.assign(kSize, 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
static constexpr size_t kFftSize = 2048; // -> 1024 dB bins
|
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 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<double>(sampleRate) : rateMax;
|
||||||
|
|
||||||
|
double samples = base;
|
||||||
|
if (safeRate < rateMin) {
|
||||||
|
samples = base * (safeRate / rateMin);
|
||||||
|
} else if (safeRate > rateMax) {
|
||||||
|
samples = base * (safeRate / rateMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
return static_cast<size_t>(std::clamp(std::round(samples), 64.0, 32767.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t scopeOutputDelaySamples(size_t sampleRate) {
|
||||||
|
const double samples = static_cast<double>(sampleRate) * kScopeOutputDelaySeconds;
|
||||||
|
return static_cast<size_t>(std::clamp(
|
||||||
|
std::round(samples),
|
||||||
|
0.0,
|
||||||
|
static_cast<double>(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<double>(now - oscLastDrainTime_).count();
|
||||||
|
elapsedSeconds = std::clamp(elapsedSeconds, 0.0, 0.1);
|
||||||
|
}
|
||||||
|
oscLastDrainTime_ = now;
|
||||||
|
|
||||||
|
double desired = elapsedSeconds * static_cast<double>(sampleRate) + oscDrainCarrySamples_;
|
||||||
|
size_t budget = static_cast<size_t>(std::floor(desired));
|
||||||
|
oscDrainCarrySamples_ = desired - static_cast<double>(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.
|
// Shared SPSC state.
|
||||||
std::vector<float> ring_;
|
std::vector<float> ring_;
|
||||||
@@ -119,6 +276,15 @@ class ScopeDriver {
|
|||||||
std::vector<float> scratch_;
|
std::vector<float> scratch_;
|
||||||
Visualizer::Spectrum spectrum_;
|
Visualizer::Spectrum spectrum_;
|
||||||
int appliedSampleRate_{0};
|
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
|
} // namespace astra
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export const SPECTRUM_BINS = 1024;
|
|||||||
export const SPECTRUM_DB_MIN = -100;
|
export const SPECTRUM_DB_MIN = -100;
|
||||||
export const SPECTRUM_DB_MAX = 0;
|
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 {
|
declare class AstraScopeModuleType extends NativeModule {
|
||||||
/** Gate the audio-thread PCM tap. Off when backgrounded/paused/reduced-motion. */
|
/** Gate the audio-thread PCM tap. Off when backgrounded/paused/reduced-motion. */
|
||||||
setActive(active: boolean): void;
|
setActive(active: boolean): void;
|
||||||
@@ -16,6 +19,12 @@ declare class AstraScopeModuleType extends NativeModule {
|
|||||||
* per render frame from the JS thread.
|
* per render frame from the JS thread.
|
||||||
*/
|
*/
|
||||||
getSpectrumFrame(out: Float32Array): number;
|
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<AstraScopeModuleType>('AstraScope');
|
export const AstraScope = requireNativeModule<AstraScopeModuleType>('AstraScope');
|
||||||
|
|||||||
+37
-1
@@ -23,6 +23,7 @@ import { colors, radius, spacing } from '@/theme';
|
|||||||
import { motion } from '@/theme/motion';
|
import { motion } from '@/theme/motion';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||||
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
import {
|
import {
|
||||||
cycleRepeat,
|
cycleRepeat,
|
||||||
seekTo,
|
seekTo,
|
||||||
@@ -130,6 +131,7 @@ export default function NowPlayingScreen() {
|
|||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
const [showScopeStage, setShowScopeStage] = useState(false);
|
const [showScopeStage, setShowScopeStage] = useState(false);
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||||
const track = usePlayerStore((s) => s.currentTrack);
|
const track = usePlayerStore((s) => s.currentTrack);
|
||||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||||
@@ -259,9 +261,30 @@ export default function NowPlayingScreen() {
|
|||||||
height={layout.scopeHeight}
|
height={layout.scopeHeight}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
showChrome={false}
|
showChrome={false}
|
||||||
mode="spectrum"
|
mode={scopeMode}
|
||||||
edgeFade
|
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). */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() =>
|
||||||
|
useSettingsStore
|
||||||
|
.getState()
|
||||||
|
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum')
|
||||||
|
}
|
||||||
|
hitSlop={12}
|
||||||
|
style={styles.scopeSwap}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Showing ${
|
||||||
|
scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope'
|
||||||
|
}. Tap to switch.`}
|
||||||
|
>
|
||||||
|
<Text variant="caption" style={styles.scopeSwapLabel}>
|
||||||
|
{scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
@@ -476,6 +499,19 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
scopeSwap: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: spacing.xs,
|
||||||
|
right: spacing.sm,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
scopeSwapLabel: {
|
||||||
|
color: colors.textTertiary,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
artImage: {
|
artImage: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { colors, radius, spacing } from '@/theme';
|
|||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { skipToNext, togglePlay } from '@/audio/playbackController';
|
import { skipToNext, togglePlay } from '@/audio/playbackController';
|
||||||
import { useScopeActive } from '@/scope/scopeStore';
|
import { useScopeActive } from '@/scope/scopeStore';
|
||||||
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
|
|
||||||
|
|
||||||
const PILL_HEIGHT = 56;
|
const PILL_HEIGHT = 56;
|
||||||
const ART = 42;
|
const ART = 42;
|
||||||
@@ -29,7 +28,6 @@ export function MiniPlayer() {
|
|||||||
const duration = usePlayerStore((s) => s.duration);
|
const duration = usePlayerStore((s) => s.duration);
|
||||||
|
|
||||||
const scopeActive = useScopeActive();
|
const scopeActive = useScopeActive();
|
||||||
const values = useSpectrumCurve(CURVE_POINTS, scopeActive);
|
|
||||||
const [pillWidth, setPillWidth] = useState(0);
|
const [pillWidth, setPillWidth] = useState(0);
|
||||||
|
|
||||||
if (!track) return null;
|
if (!track) return null;
|
||||||
@@ -45,11 +43,16 @@ export function MiniPlayer() {
|
|||||||
{scopeActive && pillWidth > 0 && (
|
{scopeActive && pillWidth > 0 && (
|
||||||
<View pointerEvents="none" style={styles.spectrum}>
|
<View pointerEvents="none" style={styles.spectrum}>
|
||||||
<SpectrumCurve
|
<SpectrumCurve
|
||||||
values={values}
|
active={scopeActive}
|
||||||
|
pointCount={CURVE_POINTS}
|
||||||
|
analysisFrameMs={0}
|
||||||
|
dbMin={-84}
|
||||||
|
dbMax={-20}
|
||||||
width={pillWidth}
|
width={pillWidth}
|
||||||
height={PILL_HEIGHT}
|
height={PILL_HEIGHT}
|
||||||
lineWidth={1.5}
|
lineWidth={1.5}
|
||||||
fillOpacity={0.5}
|
fillOpacity={0.75}
|
||||||
|
glow
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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: <T>(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<SkiaPictureView | null>(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 <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OscilloscopeWave;
|
||||||
@@ -1,25 +1,62 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import { Canvas, Group, LinearGradient, Path, Rect, Skia, vec } from '@shopify/react-native-skia';
|
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';
|
import { colors } from '@/theme';
|
||||||
|
|
||||||
interface SpectrumCurveProps {
|
interface SpectrumCurveProps {
|
||||||
/** Normalized magnitudes in [0,1], one per point (see useSpectrumCurve). */
|
/** Normalized magnitudes in [0,1] for static rendering. Live rendering ignores this. */
|
||||||
values: number[];
|
values?: ArrayLike<number>;
|
||||||
width: number;
|
width: number;
|
||||||
height: 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;
|
color?: string;
|
||||||
lineWidth?: number;
|
lineWidth?: number;
|
||||||
/** 0..1 multiplier on the gradient fill under the line. */
|
|
||||||
fillOpacity?: number;
|
fillOpacity?: number;
|
||||||
/** Adds a soft wider stroke under the line for a glow. */
|
|
||||||
glow?: boolean;
|
glow?: boolean;
|
||||||
/** Fades the left/right edges into the background so stage views do not end abruptly. */
|
|
||||||
edgeFade?: boolean;
|
edgeFade?: boolean;
|
||||||
edgeFadeColor?: string;
|
edgeFadeColor?: string;
|
||||||
edgeFadeWidth?: number;
|
edgeFadeWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SkiaViewApiShape = {
|
||||||
|
setJsiProperty: <T>(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. */
|
/** #rrggbb -> rgba() with the given alpha. */
|
||||||
function withAlpha(hex: string, alpha: number): string {
|
function withAlpha(hex: string, alpha: number): string {
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
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})`;
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function makeStrokePaint(color: string, width: number, alpha = 1) {
|
||||||
* Builds a smooth (quadratic-through-midpoints) path for the line, plus a copy
|
const paint = Skia.Paint();
|
||||||
* closed to the baseline for the gradient fill. Same curve the desktop spectrum
|
paint.setAntiAlias(true);
|
||||||
* draws, ported to Skia.
|
paint.setColor(Skia.Color(alpha === 1 ? color : withAlpha(color, alpha)));
|
||||||
*/
|
paint.setStrokeWidth(width);
|
||||||
function buildPaths(values: number[], width: number, height: number, pad: number) {
|
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<number>, width: number, height: number, pad: number) {
|
||||||
const line = Skia.Path.Make();
|
const line = Skia.Path.Make();
|
||||||
const n = values.length;
|
const n = values.length;
|
||||||
if (n < 2 || width <= 0 || height <= 0) return { line, fill: line.copy() };
|
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 };
|
return { line, fill };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPicture(
|
||||||
|
values: ArrayLike<number>,
|
||||||
|
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
|
* Filled-line spectrum. When `active` is true this mirrors the oscilloscope hot
|
||||||
* gradient fill. Source-agnostic — give it normalized values and a size.
|
* path: a frame loop pulls native data and updates the Skia view imperatively.
|
||||||
*/
|
*/
|
||||||
export function SpectrumCurve({
|
export function SpectrumCurve({
|
||||||
values,
|
values,
|
||||||
width,
|
width,
|
||||||
height,
|
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,
|
color = colors.accent,
|
||||||
lineWidth = 2,
|
lineWidth = 2,
|
||||||
fillOpacity = 1,
|
fillOpacity = 1,
|
||||||
@@ -77,63 +302,110 @@ export function SpectrumCurve({
|
|||||||
edgeFadeColor = colors.bgPrimary,
|
edgeFadeColor = colors.bgPrimary,
|
||||||
edgeFadeWidth = 28,
|
edgeFadeWidth = 28,
|
||||||
}: SpectrumCurveProps) {
|
}: SpectrumCurveProps) {
|
||||||
const pad = lineWidth;
|
const viewRef = useRef<SkiaPictureView | null>(null);
|
||||||
const { line, fill } = useMemo(
|
const activePointCount = Math.max(2, Math.floor(width));
|
||||||
() => buildPaths(values, width, height, pad),
|
const resolvedPointCount = pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS);
|
||||||
[values, width, height, pad]
|
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;
|
if (width <= 0 || height <= 0) return null;
|
||||||
|
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
|
||||||
return (
|
|
||||||
<Canvas style={{ width, height }}>
|
|
||||||
<Group opacity={fillOpacity}>
|
|
||||||
<Path path={fill}>
|
|
||||||
<LinearGradient
|
|
||||||
start={vec(0, 0)}
|
|
||||||
end={vec(0, height)}
|
|
||||||
colors={[withAlpha(color, 0.38), withAlpha(color, 0.08), withAlpha(color, 0)]}
|
|
||||||
/>
|
|
||||||
</Path>
|
|
||||||
</Group>
|
|
||||||
{glow && (
|
|
||||||
<Path
|
|
||||||
path={line}
|
|
||||||
style="stroke"
|
|
||||||
strokeWidth={lineWidth * 3}
|
|
||||||
strokeJoin="round"
|
|
||||||
strokeCap="round"
|
|
||||||
color={withAlpha(color, 0.18)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Path
|
|
||||||
path={line}
|
|
||||||
style="stroke"
|
|
||||||
strokeWidth={lineWidth}
|
|
||||||
strokeJoin="round"
|
|
||||||
strokeCap="round"
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
{edgeFade && (
|
|
||||||
<>
|
|
||||||
<Rect x={0} y={0} width={edgeFadeWidth} height={height}>
|
|
||||||
<LinearGradient
|
|
||||||
start={vec(0, 0)}
|
|
||||||
end={vec(edgeFadeWidth, 0)}
|
|
||||||
colors={[edgeFadeColor, withAlpha(edgeFadeColor, 0)]}
|
|
||||||
/>
|
|
||||||
</Rect>
|
|
||||||
<Rect x={width - edgeFadeWidth} y={0} width={edgeFadeWidth} height={height}>
|
|
||||||
<LinearGradient
|
|
||||||
start={vec(width - edgeFadeWidth, 0)}
|
|
||||||
end={vec(width, 0)}
|
|
||||||
colors={[withAlpha(edgeFadeColor, 0), edgeFadeColor]}
|
|
||||||
/>
|
|
||||||
</Rect>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Canvas>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SpectrumCurve;
|
export default SpectrumCurve;
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { Pressable, StyleSheet, View } from 'react-native';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Text } from './Text';
|
import { Text } from './Text';
|
||||||
import { SpectrumCurve } from './SpectrumCurve';
|
import { SpectrumCurve } from './SpectrumCurve';
|
||||||
|
import { OscilloscopeWave } from './OscilloscopeWave';
|
||||||
import { colors, spacing } from '@/theme';
|
import { colors, spacing } from '@/theme';
|
||||||
import { useScopeActive } from '@/scope/scopeStore';
|
import { useScopeActive } from '@/scope/scopeStore';
|
||||||
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
|
|
||||||
|
|
||||||
const CANVAS_HEIGHT = 96;
|
const CANVAS_HEIGHT = 96;
|
||||||
const POINTS = 120;
|
const STAGE_FRAME_MS = 0; // display-sync
|
||||||
|
|
||||||
type Mode = 'spectrum' | 'scope';
|
type Mode = 'spectrum' | 'scope';
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export function Visualizer({
|
|||||||
const mode = controlledMode ?? uncontrolledMode;
|
const mode = controlledMode ?? uncontrolledMode;
|
||||||
const scopeActive = useScopeActive();
|
const scopeActive = useScopeActive();
|
||||||
const spectrumActive = scopeActive && mode === 'spectrum';
|
const spectrumActive = scopeActive && mode === 'spectrum';
|
||||||
const values = useSpectrumCurve(POINTS, spectrumActive);
|
const scopeWaveActive = scopeActive && mode === 'scope';
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
if (controlledMode) return;
|
if (controlledMode) return;
|
||||||
@@ -58,14 +58,22 @@ export function Visualizer({
|
|||||||
|
|
||||||
<View style={{ width, height }}>
|
<View style={{ width, height }}>
|
||||||
{mode === 'spectrum' ? (
|
{mode === 'spectrum' ? (
|
||||||
<SpectrumCurve values={values} width={width} height={height} glow edgeFade={edgeFade} />
|
<SpectrumCurve
|
||||||
|
active={spectrumActive}
|
||||||
|
frameMs={STAGE_FRAME_MS}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
glow
|
||||||
|
edgeFade={edgeFade}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.placeholder}>
|
<OscilloscopeWave
|
||||||
<Ionicons name="pulse-outline" size={20} color={colors.textTertiary} />
|
active={scopeWaveActive}
|
||||||
<Text variant="caption" style={styles.placeholderText}>
|
width={width}
|
||||||
OSCILLOSCOPE · COMING SOON
|
height={height}
|
||||||
</Text>
|
glow
|
||||||
</View>
|
edgeFade={edgeFade}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
@@ -109,17 +117,6 @@ const styles = StyleSheet.create({
|
|||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
},
|
},
|
||||||
placeholder: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
placeholderText: {
|
|
||||||
color: colors.textTertiary,
|
|
||||||
letterSpacing: 1.5,
|
|
||||||
fontSize: 10,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Visualizer;
|
export default Visualizer;
|
||||||
|
|||||||
@@ -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<number[]>(() => new Array(pointCount).fill(0));
|
|
||||||
const zeros = useMemo(() => new Array<number>(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<number>(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;
|
|
||||||
}
|
|
||||||
@@ -9,27 +9,45 @@ import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
|||||||
* subscribes here to recompute the artist list when the grouping mode changes.
|
* subscribes here to recompute the artist list when the grouping mode changes.
|
||||||
*/
|
*/
|
||||||
const ARTIST_GROUPING_KEY = 'artist_grouping_mode';
|
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 {
|
function parseGroupingMode(value: string | null): ArtistGroupingMode {
|
||||||
return value === 'fileTags' ? 'fileTags' : 'astra';
|
return value === 'fileTags' ? 'fileTags' : 'astra';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseScopeMode(value: string | null): ScopeMode {
|
||||||
|
return value === 'scope' ? 'scope' : 'spectrum';
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsStore {
|
interface SettingsStore {
|
||||||
artistGroupingMode: ArtistGroupingMode;
|
artistGroupingMode: ArtistGroupingMode;
|
||||||
|
scopeMode: ScopeMode;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
load: () => Promise<void>;
|
load: () => Promise<void>;
|
||||||
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
|
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
|
||||||
|
setScopeMode: (mode: ScopeMode) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||||
artistGroupingMode: 'astra',
|
artistGroupingMode: 'astra',
|
||||||
|
scopeMode: 'spectrum',
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
|
||||||
load: async () => {
|
load: async () => {
|
||||||
if (get().loaded) return;
|
if (get().loaded) return;
|
||||||
const db = await openLibraryDb();
|
const db = await openLibraryDb();
|
||||||
const stored = await getSetting(db, ARTIST_GROUPING_KEY);
|
const [grouping, scope] = await Promise.all([
|
||||||
set({ artistGroupingMode: parseGroupingMode(stored), loaded: true });
|
getSetting(db, ARTIST_GROUPING_KEY),
|
||||||
|
getSetting(db, SCOPE_MODE_KEY),
|
||||||
|
]);
|
||||||
|
set({
|
||||||
|
artistGroupingMode: parseGroupingMode(grouping),
|
||||||
|
scopeMode: parseScopeMode(scope),
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setArtistGroupingMode: async (mode) => {
|
setArtistGroupingMode: async (mode) => {
|
||||||
@@ -38,4 +56,11 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
const db = await openLibraryDb();
|
const db = await openLibraryDb();
|
||||||
await setSetting(db, ARTIST_GROUPING_KEY, mode);
|
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);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user