mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
m3, ui/ux, and more
This commit is contained in:
+168
@@ -3,12 +3,16 @@ package expo.modules.astralibraryscanner
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.functions.Coroutine
|
||||
import expo.modules.kotlin.modules.Module
|
||||
@@ -37,6 +41,9 @@ class AstraLibraryScannerModule : Module() {
|
||||
// read and hashed once, not once per track.
|
||||
private val coverHashMemo = ConcurrentHashMap<String, String>()
|
||||
|
||||
// Waveform decode is whole-file and CPU-heavy; throttle concurrent decodes.
|
||||
private val waveformSemaphore = Semaphore(2)
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("AstraLibraryScanner")
|
||||
|
||||
@@ -55,6 +62,15 @@ class AstraLibraryScannerModule : Module() {
|
||||
}
|
||||
}
|
||||
|
||||
// Offline waveform peaks for the seek bar: full PCM decode -> RMS per bin,
|
||||
// normalized to [0,1]. Heavy (whole-file decode), so cap concurrency and
|
||||
// run lazily per track on the JS side; results are cached in SQLite there.
|
||||
AsyncFunction("extractWaveform") Coroutine { uri: String, bins: Int ->
|
||||
waveformSemaphore.withPermit {
|
||||
withContext(Dispatchers.IO) { extractWaveform(uri, if (bins > 0) bins else 512) }
|
||||
}
|
||||
}
|
||||
|
||||
Function("getArtworkDirPath") {
|
||||
artworkDir().absolutePath
|
||||
}
|
||||
@@ -267,6 +283,158 @@ class AstraLibraryScannerModule : Module() {
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Waveform peaks (offline RMS bins)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Decodes the whole file to PCM and accumulates RMS energy per bin (mirrors
|
||||
// desktop waveformExtractor.extractWaveformPeaks), normalized to [0,1]. Returns
|
||||
// an empty array on any failure (caller falls back to a flat seek bar).
|
||||
private fun extractWaveform(uriStr: String, bins: Int): FloatArray {
|
||||
val context = requireContext()
|
||||
val uri = Uri.parse(uriStr)
|
||||
val extractor = MediaExtractor()
|
||||
var codec: MediaCodec? = null
|
||||
try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
|
||||
var trackFormat: MediaFormat? = null
|
||||
var trackIndex = -1
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val f = extractor.getTrackFormat(i)
|
||||
if (f.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) {
|
||||
trackFormat = f; trackIndex = i; break
|
||||
}
|
||||
}
|
||||
val format = trackFormat ?: return FloatArray(0)
|
||||
extractor.selectTrack(trackIndex)
|
||||
|
||||
val sampleRate =
|
||||
if (format.containsKey(MediaFormat.KEY_SAMPLE_RATE)) format.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100
|
||||
val durationUs =
|
||||
if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L
|
||||
val totalFrames = max(1L, (durationUs / 1_000_000.0 * sampleRate).toLong())
|
||||
var channelCount =
|
||||
if (format.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) else 2
|
||||
var pcmFloat = false
|
||||
|
||||
val sumSquares = DoubleArray(bins)
|
||||
val counts = LongArray(bins)
|
||||
|
||||
codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!)
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var sawInputEOS = false
|
||||
var sawOutputEOS = false
|
||||
var frame = 0L
|
||||
|
||||
while (!sawOutputEOS) {
|
||||
if (!sawInputEOS) {
|
||||
val inIndex = codec.dequeueInputBuffer(10_000)
|
||||
if (inIndex >= 0) {
|
||||
val inBuf = codec.getInputBuffer(inIndex)!!
|
||||
val size = extractor.readSampleData(inBuf, 0)
|
||||
if (size < 0) {
|
||||
codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
sawInputEOS = true
|
||||
} else {
|
||||
codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val outIndex = codec.dequeueOutputBuffer(info, 10_000)
|
||||
if (outIndex >= 0) {
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) sawOutputEOS = true
|
||||
if (info.size > 0) {
|
||||
val out = codec.getOutputBuffer(outIndex)!!
|
||||
out.position(info.offset)
|
||||
out.limit(info.offset + info.size)
|
||||
out.order(ByteOrder.nativeOrder())
|
||||
frame = accumulate(out, pcmFloat, channelCount, bins, totalFrames, frame, sumSquares, counts)
|
||||
}
|
||||
codec.releaseOutputBuffer(outIndex, false)
|
||||
} else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
val nf = codec.outputFormat
|
||||
if (nf.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) channelCount = nf.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
if (nf.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
|
||||
pcmFloat = nf.getInteger(MediaFormat.KEY_PCM_ENCODING) == AudioFormat.ENCODING_PCM_FLOAT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val peaks = FloatArray(bins)
|
||||
var globalMax = 0.0
|
||||
for (i in 0 until bins) {
|
||||
if (counts[i] > 0) {
|
||||
val rms = sqrt(sumSquares[i] / counts[i])
|
||||
peaks[i] = rms.toFloat()
|
||||
if (rms > globalMax) globalMax = rms
|
||||
}
|
||||
}
|
||||
if (globalMax > 0) {
|
||||
for (i in 0 until bins) peaks[i] = (peaks[i] / globalMax).toFloat()
|
||||
}
|
||||
return peaks
|
||||
} catch (_: Throwable) {
|
||||
return FloatArray(0)
|
||||
} finally {
|
||||
try { codec?.stop() } catch (_: Throwable) {}
|
||||
try { codec?.release() } catch (_: Throwable) {}
|
||||
try { extractor.release() } catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Folds one decoded PCM buffer into the per-bin RMS accumulators. Handles
|
||||
// 16-bit (default) and float PCM. Returns the updated running frame index.
|
||||
private fun accumulate(
|
||||
out: java.nio.ByteBuffer,
|
||||
pcmFloat: Boolean,
|
||||
channelCount: Int,
|
||||
bins: Int,
|
||||
totalFrames: Long,
|
||||
startFrame: Long,
|
||||
sumSquares: DoubleArray,
|
||||
counts: LongArray
|
||||
): Long {
|
||||
var frame = startFrame
|
||||
if (pcmFloat) {
|
||||
val fb = out.asFloatBuffer()
|
||||
val n = fb.remaining()
|
||||
var k = 0
|
||||
while (k < n) {
|
||||
val bin = ((frame.toDouble() / totalFrames) * bins).toInt().coerceIn(0, bins - 1)
|
||||
var c = 0
|
||||
while (c < channelCount && k < n) {
|
||||
val s = fb.get(k).toDouble()
|
||||
sumSquares[bin] += s * s
|
||||
k++; c++
|
||||
}
|
||||
counts[bin] += c.toLong()
|
||||
frame++
|
||||
}
|
||||
} else {
|
||||
val sb = out.asShortBuffer()
|
||||
val n = sb.remaining()
|
||||
var k = 0
|
||||
while (k < n) {
|
||||
val bin = ((frame.toDouble() / totalFrames) * bins).toInt().coerceIn(0, bins - 1)
|
||||
var c = 0
|
||||
while (c < channelCount && k < n) {
|
||||
val s = sb.get(k) / 32768.0
|
||||
sumSquares[bin] += s * s
|
||||
k++; c++
|
||||
}
|
||||
counts[bin] += c.toLong()
|
||||
frame++
|
||||
}
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
private fun readBitsPerSample(format: MediaFormat): Int? {
|
||||
// The framework FLAC/WAV extractors expose "bits-per-sample"; other codecs
|
||||
// may expose a PCM encoding instead. Both are best-effort.
|
||||
|
||||
@@ -55,6 +55,11 @@ type AstraLibraryScannerEvents = {
|
||||
declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibraryScannerEvents> {
|
||||
listAudioFiles(treeUri: string, extensions: string[]): Promise<ListResult>;
|
||||
extractMetadata(files: { uri: string; coverUri?: string | null }[]): Promise<ExtractedMetadata[]>;
|
||||
/**
|
||||
* Decode the file's PCM and return `bins` RMS peaks normalized to [0,1] for
|
||||
* the waveform seek bar. Whole-file decode (heavy); returns [] on failure.
|
||||
*/
|
||||
extractWaveform(uri: string, bins: number): Promise<number[]>;
|
||||
getArtworkDirPath(): string;
|
||||
getPersistedTreeUris(): string[];
|
||||
takePersistableUriPermission(uri: string): Promise<boolean>;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(astrascope)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# Pure portable DSP + a plain-JNI bridge. No prefab / ReactAndroid / fbjni:
|
||||
# <jni.h> comes from the NDK sysroot and we only link liblog.
|
||||
add_library(astrascope SHARED
|
||||
../cpp/dsp_utils.cpp
|
||||
../cpp/spectrum.cpp
|
||||
../cpp/oscilloscope.cpp
|
||||
../cpp/vectorscope.cpp
|
||||
../cpp/scope_jni.cpp)
|
||||
|
||||
target_include_directories(astrascope PRIVATE ../cpp)
|
||||
|
||||
find_library(log-lib log)
|
||||
target_link_libraries(astrascope ${log-lib})
|
||||
@@ -0,0 +1,35 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'expo.modules.astrascope'
|
||||
version = '0.1.0'
|
||||
|
||||
def reactNativeArchitectures() {
|
||||
def value = project.getProperties().get("reactNativeArchitectures")
|
||||
return value ? value.split(",") : ["armeabi-v7a", "arm64-v8a", "x86", "x86_64"]
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.astrascope"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags "-O3 -std=c++17 -fexceptions -frtti"
|
||||
abiFilters(*reactNativeArchitectures())
|
||||
arguments "-DANDROID_STL=c++_shared"
|
||||
}
|
||||
}
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path "CMakeLists.txt"
|
||||
}
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
@@ -0,0 +1,28 @@
|
||||
package expo.modules.astrascope
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import expo.modules.kotlin.typedarray.Float32Array
|
||||
|
||||
/**
|
||||
* JS surface for the realtime scope. Both functions are synchronous (JSI):
|
||||
* [getSpectrumFrame] is pulled once per render frame from the JS thread and
|
||||
* fills a JS-preallocated Float32Array in place (no per-frame allocation, no
|
||||
* event-emitter traffic). The PCM that feeds it arrives on the audio thread via
|
||||
* the vendored kotlin-audio tap -> [ScopeBridge].
|
||||
*/
|
||||
class AstraScopeModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("AstraScope")
|
||||
|
||||
// Gate the audio-thread tap (off when backgrounded/paused/reduced-motion).
|
||||
Function("setActive") { active: Boolean ->
|
||||
ScopeBridge.active = active
|
||||
}
|
||||
|
||||
// Fill `out` with the latest dB spectrum; returns the number of bins written.
|
||||
Function("getSpectrumFrame") { out: Float32Array ->
|
||||
ScopeBridge.nativeFillSpectrum(out.toDirectBuffer(), out.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package expo.modules.astrascope
|
||||
|
||||
/**
|
||||
* Process-wide bridge to the native scope driver (libastrascope.so).
|
||||
*
|
||||
* Loaded once here; the vendored kotlin-audio PCM tap (ScopeTapAudioProcessor)
|
||||
* calls [nativePushFrames]/[nativeConfigure] from the ExoPlayer audio thread,
|
||||
* while [AstraScopeModule] calls [nativeFillSpectrum] from the JS thread. The
|
||||
* native side is single-producer/single-consumer and lock-free on the audio
|
||||
* path; see scope_ring.h.
|
||||
*
|
||||
* [active] gates the tap so a backgrounded/paused app pays ~zero in the audio
|
||||
* callback. The lifecycle owner (RN side) flips it via AstraScope.setActive().
|
||||
*/
|
||||
object ScopeBridge {
|
||||
init {
|
||||
System.loadLibrary("astrascope")
|
||||
}
|
||||
|
||||
/** Set by the lifecycle owner; checked cheaply in the audio callback. */
|
||||
@Volatile
|
||||
var active: Boolean = false
|
||||
|
||||
/** Audio thread. Tell the analyzer the stream's sample rate / channels. */
|
||||
external fun nativeConfigure(sampleRate: Int, channelCount: Int)
|
||||
|
||||
/** Audio thread. Push interleaved float PCM (frameCount * channelCount). */
|
||||
external fun nativePushFrames(frames: FloatArray, frameCount: Int, channelCount: Int)
|
||||
|
||||
/**
|
||||
* Render thread. Fill `buffer` (a direct ByteBuffer over the JS Float32Array's
|
||||
* memory) with the latest dB spectrum, up to `capacityFloats` floats.
|
||||
* Returns the number of bins written. Zero-copy: writes straight into JS memory.
|
||||
*/
|
||||
external fun nativeFillSpectrum(buffer: java.nio.ByteBuffer, capacityFloats: Int): Int
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
#define _USE_MATH_DEFINES
|
||||
#include "dsp_utils.h"
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
namespace DSP {
|
||||
|
||||
// FFT Implementation
|
||||
FFT::FFT(size_t size) : size_(size) {
|
||||
// Precompute twiddle factors
|
||||
twiddles_.resize(size / 2);
|
||||
for (size_t i = 0; i < size / 2; i++) {
|
||||
float angle = -2.0f * M_PI * i / size;
|
||||
twiddles_[i] = std::complex<float>(cosf(angle), sinf(angle));
|
||||
}
|
||||
buffer_.resize(size);
|
||||
scratch_.resize(size);
|
||||
}
|
||||
|
||||
void FFT::bitReverse(std::complex<float>* data) {
|
||||
size_t n = size_;
|
||||
for (size_t i = 1, j = 0; i < n; i++) {
|
||||
size_t bit = n >> 1;
|
||||
while (j & bit) {
|
||||
j ^= bit;
|
||||
bit >>= 1;
|
||||
}
|
||||
j ^= bit;
|
||||
if (i < j) {
|
||||
std::swap(data[i], data[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FFT::forward(const float* input, std::complex<float>* output) {
|
||||
// Copy input to internal buffer
|
||||
for (size_t i = 0; i < size_; i++) {
|
||||
buffer_[i] = std::complex<float>(input[i], 0.0f);
|
||||
}
|
||||
|
||||
bitReverse(buffer_.data());
|
||||
|
||||
// Cooley-Tukey FFT
|
||||
for (size_t len = 2; len <= size_; len *= 2) {
|
||||
size_t halfLen = len / 2;
|
||||
size_t step = size_ / len;
|
||||
for (size_t i = 0; i < size_; i += len) {
|
||||
for (size_t j = 0; j < halfLen; j++) {
|
||||
std::complex<float> t = twiddles_[j * step] * buffer_[i + j + halfLen];
|
||||
buffer_[i + j + halfLen] = buffer_[i + j] - t;
|
||||
buffer_[i + j] = buffer_[i + j] + t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memcpy(output, buffer_.data(), size_ * sizeof(std::complex<float>));
|
||||
}
|
||||
|
||||
void FFT::forward(const float* input, float* magnitudes) {
|
||||
// Use scratch buffer for complex output to avoid allocation
|
||||
forward(input, scratch_.data());
|
||||
|
||||
// Calculate magnitudes (only first half is useful)
|
||||
// Scale by 2/N for correct magnitude
|
||||
float scale = 2.0f / size_;
|
||||
for (size_t i = 0; i < size_ / 2; i++) {
|
||||
magnitudes[i] = std::abs(scratch_[i]) * scale;
|
||||
}
|
||||
}
|
||||
|
||||
// BiquadFilter Implementation
|
||||
BiquadFilter::BiquadFilter()
|
||||
: b0_(1), b1_(0), b2_(0), a1_(0), a2_(0)
|
||||
, x1_(0), x2_(0), y1_(0), y2_(0) {}
|
||||
|
||||
void BiquadFilter::setLowpass(float frequency, float sampleRate, float Q) {
|
||||
float omega = 2.0f * M_PI * frequency / sampleRate;
|
||||
float sinOmega = sinf(omega);
|
||||
float cosOmega = cosf(omega);
|
||||
float alpha = sinOmega / (2.0f * Q);
|
||||
|
||||
float a0 = 1.0f + alpha;
|
||||
b0_ = (1.0f - cosOmega) / 2.0f / a0;
|
||||
b1_ = (1.0f - cosOmega) / a0;
|
||||
b2_ = (1.0f - cosOmega) / 2.0f / a0;
|
||||
a1_ = -2.0f * cosOmega / a0;
|
||||
a2_ = (1.0f - alpha) / a0;
|
||||
}
|
||||
|
||||
void BiquadFilter::setBandpass(float frequency, float sampleRate, float Q) {
|
||||
float omega = 2.0f * M_PI * frequency / sampleRate;
|
||||
float sinOmega = sinf(omega);
|
||||
float cosOmega = cosf(omega);
|
||||
float alpha = sinOmega / (2.0f * Q);
|
||||
|
||||
float a0 = 1.0f + alpha;
|
||||
b0_ = alpha / a0;
|
||||
b1_ = 0.0f;
|
||||
b2_ = -alpha / a0;
|
||||
a1_ = -2.0f * cosOmega / a0;
|
||||
a2_ = (1.0f - alpha) / a0;
|
||||
}
|
||||
|
||||
void BiquadFilter::setHighShelf(float frequency, float sampleRate, float gainDB, float Q) {
|
||||
float A = powf(10.0f, gainDB / 40.0f); // sqrt(10^(dB/20))
|
||||
float omega = 2.0f * M_PI * frequency / sampleRate;
|
||||
float sinOmega = sinf(omega);
|
||||
float cosOmega = cosf(omega);
|
||||
float alpha = sinOmega / (2.0f * Q);
|
||||
|
||||
float a0 = (A + 1.0f) - (A - 1.0f) * cosOmega + 2.0f * sqrtf(A) * alpha;
|
||||
b0_ = A * ((A + 1.0f) + (A - 1.0f) * cosOmega + 2.0f * sqrtf(A) * alpha) / a0;
|
||||
b1_ = -2.0f * A * ((A - 1.0f) + (A + 1.0f) * cosOmega) / a0;
|
||||
b2_ = A * ((A + 1.0f) + (A - 1.0f) * cosOmega - 2.0f * sqrtf(A) * alpha) / a0;
|
||||
a1_ = 2.0f * ((A - 1.0f) - (A + 1.0f) * cosOmega) / a0;
|
||||
a2_ = ((A + 1.0f) - (A - 1.0f) * cosOmega - 2.0f * sqrtf(A) * alpha) / a0;
|
||||
}
|
||||
|
||||
float BiquadFilter::process(float input) {
|
||||
float output = b0_ * input + b1_ * x1_ + b2_ * x2_ - a1_ * y1_ - a2_ * y2_;
|
||||
x2_ = x1_;
|
||||
x1_ = input;
|
||||
y2_ = y1_;
|
||||
y1_ = output;
|
||||
|
||||
// Denormal protection
|
||||
if (std::abs(y1_) < 1e-20f) y1_ = 0.0f;
|
||||
if (std::abs(y2_) < 1e-20f) y2_ = 0.0f;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
void BiquadFilter::reset() {
|
||||
x1_ = x2_ = y1_ = y2_ = 0.0f;
|
||||
}
|
||||
|
||||
void BiquadFilter::processBuffer(const float* input, float* output, size_t length, bool bidirectional) {
|
||||
reset();
|
||||
|
||||
// Forward pass
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
output[i] = process(input[i]);
|
||||
}
|
||||
|
||||
if (bidirectional) {
|
||||
// Backward pass for zero phase delay
|
||||
reset();
|
||||
for (int i = length - 1; i >= 0; i--) {
|
||||
output[i] = process(output[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FIRFilter Implementation
|
||||
FIRFilter::FIRFilter() : idx_(0), order_(0) {}
|
||||
|
||||
// Modified Bessel function of the first kind, order 0 (I0)
|
||||
// Approximation from Abramowitz and Stegun
|
||||
double FIRFilter::besselI0(double x) {
|
||||
double ax = std::abs(x);
|
||||
if (ax <= 3.75) {
|
||||
double y = (x / 3.75);
|
||||
y *= y;
|
||||
return 1.0 + y * (3.5156229 + y * (3.0899424 + y * (1.2067492 +
|
||||
y * (0.2659732 + y * (0.0360768 + y * 0.0045813)))));
|
||||
} else {
|
||||
double y = 3.75 / ax;
|
||||
return (std::exp(ax) / std::sqrt(ax)) * (0.39894228 +
|
||||
y * (0.01328592 + y * (0.00225319 + y * (-0.00157565 +
|
||||
y * (0.00916281 + y * (-0.02057706 + y * (0.02635537 +
|
||||
y * (-0.01647633 + y * 0.00392377))))))));
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<float> FIRFilter::kaiserWindow(size_t length, float beta) {
|
||||
std::vector<float> window(length);
|
||||
if (length == 0) return window;
|
||||
const double denom = besselI0(static_cast<double>(beta));
|
||||
const double M = static_cast<double>(length - 1);
|
||||
for (size_t n = 0; n < length; ++n) {
|
||||
double ratio = (M == 0.0) ? 0.0 : (2.0 * static_cast<double>(n) / M - 1.0);
|
||||
double val = besselI0(static_cast<double>(beta) *
|
||||
std::sqrt(std::max(0.0, 1.0 - ratio * ratio))) / denom;
|
||||
window[n] = static_cast<float>(val);
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
void FIRFilter::designBandpass(float centerFreq, float bandwidth, float sampleRate, float sidelobeAtten) {
|
||||
// Kaiser beta from sidelobe attenuation
|
||||
float beta = sidelobeAtten < 21.0f ? 0.0f
|
||||
: sidelobeAtten < 50.0f ? 0.5842f * powf(sidelobeAtten - 21.0f, 0.4f) +
|
||||
0.07886f * (sidelobeAtten - 21.0f)
|
||||
: 0.1102f * (sidelobeAtten - 8.7f);
|
||||
|
||||
// Normalized frequencies
|
||||
float wc1 = 2.0f * static_cast<float>(M_PI) * (centerFreq - bandwidth / 2.0f) / sampleRate;
|
||||
float wc2 = 2.0f * static_cast<float>(M_PI) * (centerFreq + bandwidth / 2.0f) / sampleRate;
|
||||
wc1 = std::max(wc1, 0.001f);
|
||||
wc2 = std::min(wc2, static_cast<float>(M_PI) - 0.001f);
|
||||
|
||||
// Calculate filter order
|
||||
float deltaF = (wc2 - wc1) / static_cast<float>(M_PI);
|
||||
int order = static_cast<int>((sidelobeAtten - 8) / (2.285 * deltaF * M_PI));
|
||||
order = std::clamp(order, 1, 512);
|
||||
order_ = static_cast<size_t>(order);
|
||||
|
||||
size_t len = order + 1;
|
||||
size_t centerTap = len / 2;
|
||||
|
||||
// Ideal bandpass impulse response
|
||||
std::vector<float> ideal(len);
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
if (i == centerTap) {
|
||||
ideal[i] = (wc2 - wc1) / static_cast<float>(M_PI);
|
||||
} else {
|
||||
float n = static_cast<float>(static_cast<int>(i) - static_cast<int>(centerTap));
|
||||
ideal[i] = (sinf(wc2 * n) - sinf(wc1 * n)) / (static_cast<float>(M_PI) * n);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Kaiser window
|
||||
std::vector<float> window = kaiserWindow(len, beta);
|
||||
coeffs_.resize(len);
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
coeffs_[i] = ideal[i] * window[i];
|
||||
}
|
||||
|
||||
// Normalize to unity gain at center frequency
|
||||
float centerOmega = 2.0f * static_cast<float>(M_PI) * centerFreq / sampleRate;
|
||||
float response = 0.0f;
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
response += coeffs_[i] * cosf(centerOmega *
|
||||
(static_cast<float>(i) - static_cast<float>(centerTap)));
|
||||
}
|
||||
if (std::abs(response) > 1e-6f) {
|
||||
float scale = 1.0f / response;
|
||||
for (float& coeff : coeffs_) {
|
||||
coeff *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset delay line
|
||||
delay_.resize(len, 0.0f);
|
||||
idx_ = 0;
|
||||
}
|
||||
|
||||
float FIRFilter::process(float input) {
|
||||
if (coeffs_.empty()) return input;
|
||||
|
||||
size_t nTaps = coeffs_.size();
|
||||
idx_ %= nTaps;
|
||||
delay_[idx_] = input;
|
||||
|
||||
float out = 0.0f;
|
||||
size_t firstLen = nTaps - idx_;
|
||||
|
||||
// Process first segment [idx_ .. end]
|
||||
for (size_t i = 0; i < firstLen; ++i) {
|
||||
out += coeffs_[i] * delay_[idx_ + i];
|
||||
}
|
||||
// Process second segment [0 .. idx_-1]
|
||||
for (size_t i = 0; i < idx_; ++i) {
|
||||
out += coeffs_[firstLen + i] * delay_[i];
|
||||
}
|
||||
|
||||
idx_ = (idx_ + 1) % nTaps;
|
||||
return out;
|
||||
}
|
||||
|
||||
void FIRFilter::reset() {
|
||||
std::fill(delay_.begin(), delay_.end(), 0.0f);
|
||||
idx_ = 0;
|
||||
}
|
||||
|
||||
// Pitch detection using autocorrelation
|
||||
float detectPitch(const float* data, size_t length, float sampleRate, float minFreq, float maxFreq) {
|
||||
int minPeriod = static_cast<int>(sampleRate / maxFreq);
|
||||
int maxPeriod = static_cast<int>(sampleRate / minFreq);
|
||||
|
||||
maxPeriod = std::min(maxPeriod, static_cast<int>(length / 2));
|
||||
if (maxPeriod <= minPeriod) return 0.0f;
|
||||
|
||||
float bestCorrelation = -1.0f;
|
||||
int bestPeriod = 0;
|
||||
|
||||
// Use a simplified autocorrelation: only compute for lags in range
|
||||
for (int period = minPeriod; period < maxPeriod; period++) {
|
||||
float correlation = 0.0f;
|
||||
float energy1 = 0.0f;
|
||||
float energy2 = 0.0f;
|
||||
|
||||
// Use fewer samples for performance, but enough for accuracy
|
||||
int samples = std::min(static_cast<int>(length) - period, 512);
|
||||
|
||||
for (int i = 0; i < samples; i++) {
|
||||
correlation += data[i] * data[i + period];
|
||||
energy1 += data[i] * data[i];
|
||||
energy2 += data[i + period] * data[i + period];
|
||||
}
|
||||
|
||||
// Normalized correlation
|
||||
if (energy1 > 1e-9f && energy2 > 1e-9f) {
|
||||
float norm = sqrtf(energy1 * energy2);
|
||||
correlation /= norm;
|
||||
|
||||
if (correlation > bestCorrelation) {
|
||||
bestCorrelation = correlation;
|
||||
bestPeriod = period;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Threshold for valid pitch
|
||||
if (bestCorrelation < 0.5f || bestPeriod == 0) {
|
||||
return 0.0f; // No confident pitch found
|
||||
}
|
||||
|
||||
// Parabolic interpolation for sub-sample accuracy could be added here
|
||||
// but basic integer period is often enough for visual stabilization
|
||||
|
||||
return sampleRate / bestPeriod;
|
||||
}
|
||||
|
||||
// FFT-based pitch detection (more stable than autocorrelation)
|
||||
float detectPitchFFT(const float* data, size_t length, float sampleRate, float minFreq, float maxFreq) {
|
||||
// Use power-of-2 FFT size
|
||||
size_t fftSize = 2048;
|
||||
if (length < fftSize) {
|
||||
fftSize = 1024;
|
||||
if (length < fftSize) {
|
||||
fftSize = 512;
|
||||
}
|
||||
}
|
||||
|
||||
FFT fft(fftSize);
|
||||
std::vector<float> magnitudes(fftSize / 2);
|
||||
|
||||
// Apply Hann window and run FFT
|
||||
std::vector<float> windowed(fftSize, 0.0f);
|
||||
size_t copyLen = std::min(length, fftSize);
|
||||
for (size_t i = 0; i < copyLen; i++) {
|
||||
float win = 0.5f * (1.0f - cosf(2.0f * static_cast<float>(M_PI) * i / fftSize));
|
||||
windowed[i] = data[i] * win;
|
||||
}
|
||||
fft.forward(windowed.data(), magnitudes.data());
|
||||
|
||||
// Find peak in frequency range
|
||||
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 (magnitudes[i] > peakMag) {
|
||||
peakMag = magnitudes[i];
|
||||
peakBin = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if peak is significant (avoid noise)
|
||||
if (peakMag < 1e-6f) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Quadratic interpolation for sub-bin accuracy
|
||||
if (peakBin > 0 && peakBin < static_cast<int>(fftSize / 2) - 1) {
|
||||
float y1 = magnitudes[peakBin - 1];
|
||||
float y2 = magnitudes[peakBin];
|
||||
float y3 = magnitudes[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);
|
||||
}
|
||||
|
||||
// Find zero-crossing trigger point (sub-sample precision)
|
||||
// searches in [searchStart, searchEnd)
|
||||
// Finds the STRONGEST (steepest slope) rising zero crossing for consistency
|
||||
float findTriggerPoint(const float* data, size_t length, int searchStart, int searchEnd) {
|
||||
searchStart = std::max(1, searchStart); // Need i-1
|
||||
searchEnd = std::min(static_cast<int>(length), searchEnd);
|
||||
|
||||
if (searchStart >= searchEnd) return -1.0f;
|
||||
|
||||
// Find the zero crossing with the steepest positive slope
|
||||
float bestSlope = 0.0f;
|
||||
int bestIdx = -1;
|
||||
|
||||
for (int i = searchStart; i < searchEnd; i++) {
|
||||
float prev = data[i - 1];
|
||||
float curr = data[i];
|
||||
|
||||
// Rising zero crossing: prev < 0 and curr >= 0
|
||||
if (prev < 0.0f && curr >= 0.0f) {
|
||||
float slope = curr - prev; // Always positive for rising crossing
|
||||
if (slope > bestSlope) {
|
||||
bestSlope = slope;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx < 0) return -1.0f;
|
||||
|
||||
// Linear interpolation for sub-sample precision
|
||||
float prev = data[bestIdx - 1];
|
||||
float curr = data[bestIdx];
|
||||
float t = -prev / (curr - prev);
|
||||
return static_cast<float>(bestIdx - 1) + t;
|
||||
}
|
||||
|
||||
// Calculate RMS
|
||||
float calculateRMS(const float* data, size_t length) {
|
||||
if (length == 0) return 0.0f;
|
||||
float sum = 0.0f;
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
sum += data[i] * data[i];
|
||||
}
|
||||
return sqrtf(sum / length);
|
||||
}
|
||||
|
||||
} // namespace DSP
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
#define _USE_MATH_DEFINES
|
||||
#include <cmath>
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
#include <vector>
|
||||
#include <complex>
|
||||
#include <algorithm>
|
||||
|
||||
namespace DSP {
|
||||
|
||||
// Simple FFT implementation (Cooley-Tukey radix-2)
|
||||
class FFT {
|
||||
public:
|
||||
explicit FFT(size_t size);
|
||||
void forward(const float* input, float* magnitudes);
|
||||
void forward(const float* input, std::complex<float>* output);
|
||||
size_t getSize() const { return size_; }
|
||||
|
||||
private:
|
||||
size_t size_;
|
||||
std::vector<std::complex<float>> twiddles_;
|
||||
std::vector<std::complex<float>> buffer_; // Reuse buffer to avoid allocations
|
||||
std::vector<std::complex<float>> scratch_; // Scratch buffer if needed
|
||||
void bitReverse(std::complex<float>* data);
|
||||
};
|
||||
|
||||
// Biquad filter for lowpass/bandpass
|
||||
class BiquadFilter {
|
||||
public:
|
||||
BiquadFilter();
|
||||
void setLowpass(float frequency, float sampleRate, float Q = 0.707f);
|
||||
void setBandpass(float frequency, float sampleRate, float Q = 2.0f);
|
||||
void setHighShelf(float frequency, float sampleRate, float gainDB, float Q = 0.707f);
|
||||
float process(float input);
|
||||
void reset();
|
||||
|
||||
// Process entire buffer (bidirectional for zero phase)
|
||||
void processBuffer(const float* input, float* output, size_t length, bool bidirectional = true);
|
||||
|
||||
private:
|
||||
float b0_, b1_, b2_;
|
||||
float a1_, a2_;
|
||||
float x1_, x2_;
|
||||
float y1_, y2_;
|
||||
};
|
||||
|
||||
// Linear-phase FIR filter for stable trigger detection
|
||||
// Uses Kaiser-windowed bandpass design for consistent zero crossings
|
||||
class FIRFilter {
|
||||
public:
|
||||
FIRFilter();
|
||||
|
||||
// Design Kaiser-windowed bandpass filter centered on frequency
|
||||
void designBandpass(float centerFreq, float bandwidth, float sampleRate, float sidelobeAtten = 60.0f);
|
||||
|
||||
// Process single sample
|
||||
float process(float input);
|
||||
|
||||
// Get filter delay (for phase compensation)
|
||||
size_t getDelay() const { return order_ / 2; }
|
||||
|
||||
// Reset filter state
|
||||
void reset();
|
||||
|
||||
private:
|
||||
std::vector<float> coeffs_;
|
||||
std::vector<float> delay_;
|
||||
size_t idx_;
|
||||
size_t order_;
|
||||
|
||||
// Kaiser window helpers
|
||||
static std::vector<float> kaiserWindow(size_t length, float beta);
|
||||
static double besselI0(double x);
|
||||
};
|
||||
|
||||
// Pitch detection using autocorrelation
|
||||
float detectPitch(const float* data, size_t length, float sampleRate, float minFreq = 40.0f, float maxFreq = 2000.0f);
|
||||
|
||||
// FFT-based pitch detection (more stable than autocorrelation)
|
||||
float detectPitchFFT(const float* data, size_t length, float sampleRate, float minFreq = 40.0f, float maxFreq = 2000.0f);
|
||||
|
||||
// Find zero-crossing trigger point with hysteresis/hold-off (sub-sample precision)
|
||||
float findTriggerPoint(const float* data, size_t length, int searchStart, int searchEnd);
|
||||
|
||||
// Calculate RMS
|
||||
float calculateRMS(const float* data, size_t length);
|
||||
|
||||
} // namespace DSP
|
||||
@@ -0,0 +1,331 @@
|
||||
#include "oscilloscope.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace Visualizer {
|
||||
|
||||
Oscilloscope::Oscilloscope()
|
||||
: sampleRate_(48000.0f)
|
||||
, pitchLock_(true)
|
||||
, displaySamples_(2048)
|
||||
, writePos_(0)
|
||||
, lastFilterPitch_(200.0f)
|
||||
, lastTrigger_(0)
|
||||
, smoothedPitch_(200.0f)
|
||||
, pitchSamplesProcessed_(0) {
|
||||
|
||||
// Initialize circular buffers
|
||||
circularBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
|
||||
filteredBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
|
||||
|
||||
// Initialize FIR bandpass filter centered at 200Hz with 10% bandwidth (20Hz)
|
||||
// Tight bandwidth removes harmonics, leaving only ONE rising zero crossing per period
|
||||
bandpassFilter_.designBandpass(200.0f, 20.0f, sampleRate_, 60.0f);
|
||||
|
||||
// Initialize high shelf for pitch analysis (-3dB at 400Hz, Q=0.71)
|
||||
// Reduces high frequency interference with pitch detection
|
||||
pitchAnalysisShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f);
|
||||
|
||||
// Initialize analysis and render buffers
|
||||
displayBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
|
||||
visualBuffer_.resize(OSCILLOSCOPE_BUFFER_SIZE, 0.0f);
|
||||
|
||||
// Initialize display filters (high shelf + cascaded lowpass for steep rolloff)
|
||||
displayShelf_.setHighShelf(400.0f, sampleRate_, -3.0f, 0.71f);
|
||||
displayLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
||||
displayLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
||||
|
||||
// Initialize pitch detection lowpass (cascaded for steep slope)
|
||||
pitchLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
||||
pitchLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
||||
}
|
||||
|
||||
void Oscilloscope::setSampleRate(float sampleRate) {
|
||||
sampleRate_ = 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// Update pitch detection lowpass
|
||||
pitchLowpass1_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
||||
pitchLowpass2_.setLowpass(18000.0f, sampleRate_, 0.707f);
|
||||
}
|
||||
|
||||
void Oscilloscope::setPitchLock(bool enabled) {
|
||||
pitchLock_ = enabled;
|
||||
}
|
||||
|
||||
void Oscilloscope::setDisplaySamples(int samples) {
|
||||
displaySamples_ = std::clamp(samples, 64, static_cast<int>(OSCILLOSCOPE_BUFFER_SIZE - 1));
|
||||
}
|
||||
|
||||
// Push samples into circular buffer (called from AudioWorklet)
|
||||
void Oscilloscope::pushSamples(const float* samples, size_t count) {
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
// Store raw sample
|
||||
circularBuffer_[writePos_] = samples[i];
|
||||
|
||||
// Apply FIR bandpass filter and store filtered sample
|
||||
// Linear-phase filter provides consistent zero crossings
|
||||
filteredBuffer_[writePos_] = bandpassFilter_.process(samples[i]);
|
||||
|
||||
// Tracking path: cascaded lowpass only
|
||||
float displaySample = displayLowpass1_.process(samples[i]);
|
||||
displaySample = displayLowpass2_.process(displaySample);
|
||||
displayBuffer_[writePos_] = displaySample;
|
||||
|
||||
// Visual path: high shelf on top of tracking sample
|
||||
float visualSample = displayShelf_.process(displaySample);
|
||||
visualBuffer_[writePos_] = visualSample;
|
||||
|
||||
writePos_ = (writePos_ + 1) % OSCILLOSCOPE_BUFFER_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
// Update filtered buffer from circular buffer (for backwards compatibility)
|
||||
void Oscilloscope::updateFiltered() {
|
||||
// This is called when using snapshot mode - filter is applied in pushSamples for continuous mode
|
||||
}
|
||||
|
||||
// Find trigger by searching BACKWARDS from target position
|
||||
// With tight bandpass filter (10% bandwidth), there's only ONE rising zero crossing per period
|
||||
// So we simply take the FIRST valid crossing found - no phase tracking needed
|
||||
float Oscilloscope::findTriggerBackwards(size_t target, size_t range) {
|
||||
float periodSamples = sampleRate_ / smoothedPitch_;
|
||||
|
||||
// Search backwards from target to find FIRST rising zero crossing
|
||||
for (size_t i = 0; i < range && i < OSCILLOSCOPE_BUFFER_SIZE; i++) {
|
||||
size_t pos = (target + OSCILLOSCOPE_BUFFER_SIZE - i) % OSCILLOSCOPE_BUFFER_SIZE;
|
||||
size_t prev = (pos + OSCILLOSCOPE_BUFFER_SIZE - 1) % OSCILLOSCOPE_BUFFER_SIZE;
|
||||
|
||||
float prevVal = filteredBuffer_[prev];
|
||||
float currVal = filteredBuffer_[pos];
|
||||
|
||||
// Rising zero crossing
|
||||
if (prevVal < 0.0f && currVal >= 0.0f) {
|
||||
// Check signal amplitude (look ahead ~1/4 period)
|
||||
size_t lookAhead = std::clamp(
|
||||
static_cast<size_t>(periodSamples / 4.0f),
|
||||
static_cast<size_t>(4),
|
||||
static_cast<size_t>(256)
|
||||
);
|
||||
|
||||
float peakAfter = 0.0f;
|
||||
for (size_t j = 0; j < lookAhead; j++) {
|
||||
size_t checkPos = (pos + j) % OSCILLOSCOPE_BUFFER_SIZE;
|
||||
float val = std::abs(filteredBuffer_[checkPos]);
|
||||
if (val > peakAfter) peakAfter = val;
|
||||
}
|
||||
|
||||
// Only accept if signal has significant amplitude
|
||||
if (peakAfter > 0.01f) {
|
||||
// Sub-sample interpolation for smooth rendering
|
||||
float t = -prevVal / (currVal - prevVal);
|
||||
return static_cast<float>(prev) + t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1.0f; // No crossing found
|
||||
}
|
||||
|
||||
// Process using circular buffer (continuous capture mode)
|
||||
OscilloscopeResult Oscilloscope::process() {
|
||||
OscilloscopeResult result;
|
||||
result.triggerIndex = 0;
|
||||
result.samplesToShow = displaySamples_;
|
||||
result.detectedPitch = smoothedPitch_;
|
||||
|
||||
if (!pitchLock_) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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<float> 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
|
||||
}
|
||||
|
||||
// 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]);
|
||||
}
|
||||
|
||||
// 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]);
|
||||
}
|
||||
|
||||
float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f);
|
||||
if (newPitch > 0.0f) {
|
||||
pitchSamplesProcessed_++;
|
||||
|
||||
// Adaptive smoothing: fast convergence initially, then conservative
|
||||
// First ~20 frames: use 0.5/0.5 for quick lock-on
|
||||
// After warmup: use 0.95/0.05 for stable tracking
|
||||
float smoothingOld = (pitchSamplesProcessed_ < 20) ? 0.5f : 0.95f;
|
||||
float smoothingNew = 1.0f - smoothingOld;
|
||||
smoothedPitch_ = smoothedPitch_ * smoothingOld + newPitch * smoothingNew;
|
||||
|
||||
// Redesign FIR bandpass filter if pitch changed significantly (>10%)
|
||||
// This keeps the filter centered on the fundamental for stable trigger
|
||||
if (std::abs(smoothedPitch_ - lastFilterPitch_) / lastFilterPitch_ > 0.1f) {
|
||||
float bandwidth = smoothedPitch_ * 0.1f; // 10% of center freq (tight = single zero crossing)
|
||||
bandpassFilter_.designBandpass(smoothedPitch_, bandwidth, sampleRate_, 60.0f);
|
||||
lastFilterPitch_ = smoothedPitch_;
|
||||
}
|
||||
}
|
||||
result.detectedPitch = smoothedPitch_;
|
||||
|
||||
// Calculate target position for trigger search
|
||||
// We search backwards from (writePos - displaySamples - firDelay) to find a rising zero crossing
|
||||
float periodSamples = sampleRate_ / smoothedPitch_;
|
||||
size_t samples = static_cast<size_t>(displaySamples_);
|
||||
size_t firDelay = bandpassFilter_.getDelay();
|
||||
|
||||
// Target: look back from current write position by display window size AND FIR delay
|
||||
// This ensures we're searching in the correct region where filtered data is valid
|
||||
size_t target = (writePos_ + OSCILLOSCOPE_BUFFER_SIZE - samples - firDelay) % OSCILLOSCOPE_BUFFER_SIZE;
|
||||
|
||||
// Search range: 4 periods for robust detection
|
||||
size_t range = static_cast<size_t>(periodSamples * 4.0f);
|
||||
|
||||
// Find zero crossing by searching backwards from target
|
||||
float zeroCross = findTriggerBackwards(target, range);
|
||||
|
||||
// LEFT-ANCHORED TRIGGER (MiniMeters style):
|
||||
// The zero crossing IS the left edge of display
|
||||
// Waveform starts at rising edge and extends rightward
|
||||
if (zeroCross >= 0.0f) {
|
||||
// Apply FIR filter delay compensation
|
||||
// The filtered signal is delayed by order/2 samples relative to raw signal
|
||||
size_t firDelay = bandpassFilter_.getDelay();
|
||||
|
||||
// The trigger index is where we start reading raw samples for display
|
||||
// Compensate for filter delay so trigger aligns with raw audio
|
||||
result.triggerIndex = zeroCross - static_cast<float>(firDelay);
|
||||
|
||||
// Wrap if negative
|
||||
while (result.triggerIndex < 0) {
|
||||
result.triggerIndex += OSCILLOSCOPE_BUFFER_SIZE;
|
||||
}
|
||||
} else {
|
||||
// No crossing found - use target as fallback
|
||||
result.triggerIndex = static_cast<float>(target);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Legacy snapshot processing (for backwards compatibility)
|
||||
OscilloscopeResult Oscilloscope::processSnapshot(const float* audioData, size_t length) {
|
||||
OscilloscopeResult result;
|
||||
result.triggerIndex = 0;
|
||||
result.samplesToShow = std::min(displaySamples_, static_cast<int>(length));
|
||||
result.detectedPitch = smoothedPitch_;
|
||||
|
||||
if (!pitchLock_ || length == 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Push samples to circular buffer
|
||||
pushSamples(audioData, length);
|
||||
|
||||
// Use the new continuous process method
|
||||
return process();
|
||||
}
|
||||
|
||||
// Get samples from circular buffer starting at position (integer version)
|
||||
// Returns filtered samples for display (high shelf + lowpass applied)
|
||||
void Oscilloscope::getSamples(float* output, size_t startPos, size_t count) const {
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
size_t idx = (startPos + i) % OSCILLOSCOPE_BUFFER_SIZE;
|
||||
output[i] = visualBuffer_[idx]; // Visual-only filtered signal
|
||||
}
|
||||
}
|
||||
|
||||
// Get samples with sub-sample interpolation (float start position)
|
||||
// Uses Catmull-Rom spline for smooth rendering at sub-pixel precision
|
||||
// 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<float>(i);
|
||||
|
||||
// Wrap position to buffer bounds
|
||||
while (pos < 0) pos += OSCILLOSCOPE_BUFFER_SIZE;
|
||||
while (pos >= OSCILLOSCOPE_BUFFER_SIZE) pos -= OSCILLOSCOPE_BUFFER_SIZE;
|
||||
|
||||
size_t idx = static_cast<size_t>(pos) % OSCILLOSCOPE_BUFFER_SIZE;
|
||||
float frac = pos - std::floor(pos);
|
||||
|
||||
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;
|
||||
|
||||
float y0 = visualBuffer_[i0];
|
||||
float y1 = visualBuffer_[i1];
|
||||
float y2 = visualBuffer_[i2];
|
||||
float y3 = visualBuffer_[i3];
|
||||
|
||||
// Catmull-Rom spline coefficients
|
||||
float t = frac;
|
||||
float t2 = t * t;
|
||||
float t3 = t2 * t;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Oscilloscope::reset() {
|
||||
writePos_ = 0;
|
||||
lastTrigger_ = 0.0f;
|
||||
smoothedPitch_ = 200.0f;
|
||||
lastFilterPitch_ = 200.0f;
|
||||
pitchSamplesProcessed_ = 0; // Reset warmup counter for fast convergence on next use
|
||||
|
||||
// Redesign filter to default 200Hz (reset() only clears delay line, not coefficients)
|
||||
bandpassFilter_.designBandpass(200.0f, 20.0f, sampleRate_, 60.0f);
|
||||
pitchAnalysisShelf_.reset();
|
||||
|
||||
// Reset display and pitch detection filters
|
||||
displayShelf_.reset();
|
||||
displayLowpass1_.reset();
|
||||
displayLowpass2_.reset();
|
||||
pitchLowpass1_.reset();
|
||||
pitchLowpass2_.reset();
|
||||
|
||||
// Clear buffers
|
||||
std::fill(circularBuffer_.begin(), circularBuffer_.end(), 0.0f);
|
||||
std::fill(filteredBuffer_.begin(), filteredBuffer_.end(), 0.0f);
|
||||
std::fill(displayBuffer_.begin(), displayBuffer_.end(), 0.0f);
|
||||
std::fill(visualBuffer_.begin(), visualBuffer_.end(), 0.0f);
|
||||
}
|
||||
|
||||
} // namespace Visualizer
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
|
||||
#include "dsp_utils.h"
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
namespace Visualizer {
|
||||
|
||||
struct OscilloscopeResult {
|
||||
float triggerIndex;
|
||||
int samplesToShow;
|
||||
float detectedPitch;
|
||||
};
|
||||
|
||||
// Circular buffer size (same as pulse-visualizer)
|
||||
constexpr size_t OSCILLOSCOPE_BUFFER_SIZE = 32768;
|
||||
|
||||
class Oscilloscope {
|
||||
public:
|
||||
Oscilloscope();
|
||||
|
||||
// Configuration
|
||||
void setSampleRate(float sampleRate);
|
||||
void setPitchLock(bool enabled);
|
||||
void setDisplaySamples(int samples);
|
||||
|
||||
// Push samples into circular buffer (continuous capture)
|
||||
void pushSamples(const float* samples, size_t count);
|
||||
|
||||
// Process and find trigger point (uses circular buffer)
|
||||
OscilloscopeResult process();
|
||||
|
||||
// Legacy: Process snapshot (for backwards compatibility)
|
||||
OscilloscopeResult processSnapshot(const float* audioData, size_t length);
|
||||
|
||||
// Get current write position
|
||||
size_t getWritePos() const { return writePos_; }
|
||||
|
||||
// Get samples from circular buffer (for rendering)
|
||||
void getSamples(float* output, size_t startPos, size_t count) const;
|
||||
|
||||
// Get samples with sub-sample interpolation (preserves trigger precision)
|
||||
void getSamplesInterpolated(float* output, float startPos, size_t count) const;
|
||||
|
||||
// Reset state
|
||||
void reset();
|
||||
|
||||
private:
|
||||
float sampleRate_;
|
||||
bool pitchLock_;
|
||||
int displaySamples_;
|
||||
|
||||
// Circular buffer for continuous audio
|
||||
std::vector<float> circularBuffer_;
|
||||
std::vector<float> filteredBuffer_;
|
||||
size_t writePos_;
|
||||
|
||||
// Linear-phase FIR bandpass filter for stable trigger detection
|
||||
DSP::FIRFilter bandpassFilter_;
|
||||
float lastFilterPitch_; // Track pitch for filter redesign
|
||||
|
||||
// High shelf filter to reduce HF before pitch detection
|
||||
DSP::BiquadFilter pitchAnalysisShelf_;
|
||||
|
||||
// Display filtering (high shelf + steep lowpass)
|
||||
DSP::BiquadFilter displayShelf_; // High shelf for display
|
||||
DSP::BiquadFilter displayLowpass1_; // First stage of cascaded lowpass
|
||||
DSP::BiquadFilter displayLowpass2_; // Second stage (4th order total = 24dB/oct)
|
||||
std::vector<float> displayBuffer_; // Lowpass filtered samples for tracking
|
||||
std::vector<float> visualBuffer_; // Visual-only samples (display shelf applied)
|
||||
|
||||
// Pitch detection lowpass (after existing high shelf)
|
||||
DSP::BiquadFilter pitchLowpass1_; // First stage
|
||||
DSP::BiquadFilter pitchLowpass2_; // Second stage
|
||||
|
||||
float lastTrigger_;
|
||||
float smoothedPitch_;
|
||||
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
|
||||
|
||||
// Internal helpers
|
||||
void updateFiltered();
|
||||
float findTriggerBackwards(size_t target, size_t range);
|
||||
};
|
||||
|
||||
} // namespace Visualizer
|
||||
@@ -0,0 +1,60 @@
|
||||
// Plain-JNI bridge for ScopeBridge.kt. No fbjni / ReactAndroid — this library
|
||||
// is pure DSP, so it only needs <jni.h> (NDK sysroot) and liblog.
|
||||
//
|
||||
// All JNI lives here (in the astra-scope module). The vendored kotlin-audio tap
|
||||
// calls the Kotlin ScopeBridge, never JNI directly, so libastrascope.so is
|
||||
// loaded exactly once.
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include "scope_ring.h"
|
||||
|
||||
namespace {
|
||||
astra::ScopeDriver& driver() { return astra::ScopeDriver::instance(); }
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_expo_modules_astrascope_ScopeBridge_nativeConfigure(
|
||||
JNIEnv* /*env*/, jobject /*thiz*/, jint sampleRate, jint channelCount) {
|
||||
driver().configure(static_cast<int>(sampleRate), static_cast<int>(channelCount));
|
||||
}
|
||||
|
||||
// `frames` is interleaved float PCM with frameCount * channelCount elements.
|
||||
JNIEXPORT void JNICALL
|
||||
Java_expo_modules_astrascope_ScopeBridge_nativePushFrames(
|
||||
JNIEnv* env, jobject /*thiz*/, jfloatArray frames, jint frameCount,
|
||||
jint channelCount) {
|
||||
if (frames == nullptr || frameCount <= 0 || channelCount <= 0) {
|
||||
return;
|
||||
}
|
||||
auto* data = static_cast<float*>(
|
||||
env->GetPrimitiveArrayCritical(frames, nullptr));
|
||||
if (data == nullptr) {
|
||||
return;
|
||||
}
|
||||
driver().pushInterleaved(data, static_cast<size_t>(frameCount),
|
||||
static_cast<int>(channelCount));
|
||||
// No JNI calls between Get/Release; abort copy-back (read-only access).
|
||||
env->ReleasePrimitiveArrayCritical(frames, data, JNI_ABORT);
|
||||
}
|
||||
|
||||
// Fills a direct ByteBuffer (over the JS Float32Array's memory) with the latest
|
||||
// spectrum (dB magnitudes), up to `capacityFloats` floats. Returns bin count.
|
||||
// Zero-copy: writes straight into the JS-owned ArrayBuffer.
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_expo_modules_astrascope_ScopeBridge_nativeFillSpectrum(
|
||||
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().fillSpectrum(dst, static_cast<size_t>(capacityFloats));
|
||||
return static_cast<jint>(n);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,124 @@
|
||||
#pragma once
|
||||
|
||||
// Process-wide scope driver: a single-producer / single-consumer bridge between
|
||||
// the ExoPlayer audio thread (which pushes PCM via the tap AudioProcessor) and
|
||||
// the JS render thread (which pulls the latest spectrum frame once per frame).
|
||||
//
|
||||
// Threading contract:
|
||||
// - pushInterleaved() + configure() run on the AUDIO thread. They are
|
||||
// allocation-free and lock-free: they only touch the ring (atomic write
|
||||
// position) and an atomic pending-sample-rate. They NEVER touch the
|
||||
// analyzer (no FFT on the audio callback).
|
||||
// - fillSpectrum() runs on the single JS/render thread. It owns the analyzer
|
||||
// and all consumer-only state. It snapshots the most recent fftSize mono
|
||||
// samples from the ring and runs Visualizer::Spectrum::process there.
|
||||
//
|
||||
// The ring holds mono samples (the producer downmixes), sized well above the
|
||||
// FFT window so a 60fps consumer never misses recent audio; on a snapshot we
|
||||
// read only the most recent fftSize samples, so a slow consumer simply sees the
|
||||
// latest window (correct for a rolling spectrum).
|
||||
|
||||
#include "spectrum.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace astra {
|
||||
|
||||
class ScopeDriver {
|
||||
public:
|
||||
static ScopeDriver& instance() {
|
||||
static ScopeDriver driver;
|
||||
return driver;
|
||||
}
|
||||
|
||||
// Audio thread. Cheap: just remember the rate; applied on the consumer side.
|
||||
void configure(int sampleRate, int /*channelCount*/) {
|
||||
if (sampleRate > 0) {
|
||||
pendingSampleRate_.store(sampleRate, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
// Audio thread. Downmix interleaved float frames to mono and write to ring.
|
||||
// Allocation-free and lock-free (single producer).
|
||||
void pushInterleaved(const float* data, size_t frames, int channels) {
|
||||
if (data == nullptr || frames == 0 || channels <= 0) {
|
||||
return;
|
||||
}
|
||||
size_t w = writePos_.load(std::memory_order_relaxed);
|
||||
const float inv = 1.0f / static_cast<float>(channels);
|
||||
for (size_t f = 0; f < frames; ++f) {
|
||||
float sum = 0.0f;
|
||||
const float* frame = data + f * channels;
|
||||
for (int c = 0; c < channels; ++c) {
|
||||
sum += frame[c];
|
||||
}
|
||||
ring_[w & kMask] = sum * inv;
|
||||
++w;
|
||||
}
|
||||
writePos_.store(w, std::memory_order_release);
|
||||
}
|
||||
|
||||
// Render thread (single consumer). Snapshot the most recent fftSize mono
|
||||
// samples, run the FFT, copy up to `cap` dB magnitudes into `out`.
|
||||
// Returns the number of bins written.
|
||||
size_t fillSpectrum(float* out, size_t cap) {
|
||||
if (out == nullptr || cap == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int sr = pendingSampleRate_.load(std::memory_order_acquire);
|
||||
if (sr != appliedSampleRate_) {
|
||||
spectrum_.setSampleRate(static_cast<float>(sr));
|
||||
appliedSampleRate_ = sr;
|
||||
}
|
||||
|
||||
const size_t fftSize = spectrum_.getFFTSize();
|
||||
const size_t w = writePos_.load(std::memory_order_acquire);
|
||||
|
||||
const std::vector<float>* mags;
|
||||
if (w >= fftSize) {
|
||||
scratch_.resize(fftSize);
|
||||
const size_t start = w - fftSize;
|
||||
for (size_t i = 0; i < fftSize; ++i) {
|
||||
scratch_[i] = ring_[(start + i) & kMask];
|
||||
}
|
||||
mags = &spectrum_.process(scratch_.data(), fftSize);
|
||||
} else {
|
||||
// Not enough audio yet — return current (silence-initialised) frame.
|
||||
mags = &spectrum_.process(nullptr, 0);
|
||||
}
|
||||
|
||||
const size_t n = std::min(cap, mags->size());
|
||||
std::memcpy(out, mags->data(), n * sizeof(float));
|
||||
return n;
|
||||
}
|
||||
|
||||
size_t binCount() const { return spectrum_.getFFTSize() / 2; }
|
||||
|
||||
void reset() { spectrum_.reset(); }
|
||||
|
||||
private:
|
||||
ScopeDriver() : spectrum_(kFftSize) {
|
||||
spectrum_.setSmoothing(0.9f);
|
||||
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 kMask = kSize - 1;
|
||||
|
||||
// Shared SPSC state.
|
||||
std::vector<float> ring_;
|
||||
std::atomic<size_t> writePos_{0};
|
||||
std::atomic<int> pendingSampleRate_{44100};
|
||||
|
||||
// Consumer-only state.
|
||||
std::vector<float> scratch_;
|
||||
Visualizer::Spectrum spectrum_;
|
||||
int appliedSampleRate_{0};
|
||||
};
|
||||
|
||||
} // namespace astra
|
||||
@@ -0,0 +1,132 @@
|
||||
#define _USE_MATH_DEFINES
|
||||
#include "spectrum.h"
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace Visualizer {
|
||||
|
||||
Spectrum::Spectrum(size_t fftSize)
|
||||
: fftSize_(fftSize)
|
||||
, sampleRate_(44100.0f)
|
||||
, smoothing_(0.9f)
|
||||
, bufferedSamples_(0) {
|
||||
fft_ = std::make_unique<DSP::FFT>(fftSize);
|
||||
historyBuffer_.resize(fftSize, 0.0f);
|
||||
windowedInput_.resize(fftSize);
|
||||
magnitudes_.resize(fftSize / 2);
|
||||
// Initialize to silence (-100.0f dB)
|
||||
smoothedMagnitudes_.resize(fftSize / 2, -100.0f);
|
||||
}
|
||||
|
||||
void Spectrum::setFFTSize(size_t size) {
|
||||
if (size != fftSize_) {
|
||||
fftSize_ = size;
|
||||
fft_ = std::make_unique<DSP::FFT>(size);
|
||||
historyBuffer_.assign(size, 0.0f);
|
||||
windowedInput_.resize(size);
|
||||
magnitudes_.resize(size / 2);
|
||||
// Initialize to silence (-100.0f dB)
|
||||
smoothedMagnitudes_.resize(size / 2, -100.0f);
|
||||
bufferedSamples_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void Spectrum::setSampleRate(float sampleRate) {
|
||||
sampleRate_ = sampleRate;
|
||||
}
|
||||
|
||||
void Spectrum::setSmoothing(float smoothing) {
|
||||
smoothing_ = std::clamp(smoothing, 0.0f, 0.99f);
|
||||
}
|
||||
|
||||
void Spectrum::applyWindow(const float* input, float* output, size_t length) {
|
||||
if (length <= 1) {
|
||||
if (length == 1) {
|
||||
output[0] = input[0];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hann window
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
float window = 0.5f * (1.0f - cosf(2.0f * M_PI * i / (length - 1)));
|
||||
output[i] = input[i] * window;
|
||||
}
|
||||
}
|
||||
|
||||
void Spectrum::pushSamples(const float* input, size_t length) {
|
||||
if (length == 0 || fftSize_ == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep only the most recent fftSize_ samples.
|
||||
if (length >= fftSize_) {
|
||||
std::memcpy(historyBuffer_.data(), input + (length - fftSize_), fftSize_ * sizeof(float));
|
||||
bufferedSamples_ = fftSize_;
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t keep = fftSize_ - length;
|
||||
std::move(historyBuffer_.begin() + length, historyBuffer_.end(), historyBuffer_.begin());
|
||||
std::memcpy(historyBuffer_.data() + keep, input, length * sizeof(float));
|
||||
bufferedSamples_ = std::min(fftSize_, bufferedSamples_ + length);
|
||||
}
|
||||
|
||||
const std::vector<float>& Spectrum::process(const float* audioData, size_t length) {
|
||||
if (audioData != nullptr && length > 0) {
|
||||
pushSamples(audioData, length);
|
||||
}
|
||||
|
||||
if (historyBuffer_.empty() || magnitudes_.empty()) {
|
||||
return smoothedMagnitudes_;
|
||||
}
|
||||
|
||||
// Always analyze a full FFT frame from the rolling buffer.
|
||||
applyWindow(historyBuffer_.data(), windowedInput_.data(), fftSize_);
|
||||
|
||||
// Perform FFT
|
||||
fft_->forward(windowedInput_.data(), magnitudes_.data());
|
||||
|
||||
// Convert to dB and apply smoothing
|
||||
for (size_t i = 0; i < magnitudes_.size(); i++) {
|
||||
float mag = magnitudes_[i];
|
||||
|
||||
// Convert to dB
|
||||
// Add epsilon to avoid log(0)
|
||||
float db = 20.0f * log10f(std::max(mag, 1e-10f));
|
||||
|
||||
// Compensate Hann window coherent gain (about -6 dB).
|
||||
db += 6.0f;
|
||||
|
||||
// Clamp to a stable display range.
|
||||
db = std::clamp(db, -120.0f, 12.0f);
|
||||
|
||||
if (bufferedSamples_ < fftSize_) {
|
||||
smoothedMagnitudes_[i] = db;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply temporal smoothing only (no bin-to-bin averaging).
|
||||
smoothedMagnitudes_[i] = smoothing_ * smoothedMagnitudes_[i] + (1.0f - smoothing_) * db;
|
||||
|
||||
// Safety check
|
||||
if (!std::isfinite(smoothedMagnitudes_[i])) {
|
||||
smoothedMagnitudes_[i] = -100.0f;
|
||||
}
|
||||
}
|
||||
|
||||
return smoothedMagnitudes_;
|
||||
}
|
||||
|
||||
float Spectrum::binToFrequency(int bin) const {
|
||||
return bin * sampleRate_ / fftSize_;
|
||||
}
|
||||
|
||||
void Spectrum::reset() {
|
||||
std::fill(historyBuffer_.begin(), historyBuffer_.end(), 0.0f);
|
||||
std::fill(smoothedMagnitudes_.begin(), smoothedMagnitudes_.end(), -100.0f);
|
||||
bufferedSamples_ = 0;
|
||||
}
|
||||
|
||||
} // namespace Visualizer
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include "dsp_utils.h"
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
namespace Visualizer {
|
||||
|
||||
class Spectrum {
|
||||
public:
|
||||
explicit Spectrum(size_t fftSize = 2048);
|
||||
|
||||
// Configuration
|
||||
void setFFTSize(size_t size);
|
||||
size_t getFFTSize() const { return fftSize_; }
|
||||
void setSampleRate(float sampleRate);
|
||||
void setSmoothing(float smoothing); // 0.0 - 1.0
|
||||
|
||||
// Process audio and get spectrum data
|
||||
// Returns magnitude data (size = fftSize / 2)
|
||||
const std::vector<float>& process(const float* audioData, size_t length);
|
||||
|
||||
// Get frequency for a given bin
|
||||
float binToFrequency(int bin) const;
|
||||
|
||||
// Reset state
|
||||
void reset();
|
||||
|
||||
private:
|
||||
size_t fftSize_;
|
||||
float sampleRate_;
|
||||
float smoothing_;
|
||||
|
||||
std::unique_ptr<DSP::FFT> fft_;
|
||||
std::vector<float> historyBuffer_;
|
||||
std::vector<float> windowedInput_;
|
||||
std::vector<float> magnitudes_;
|
||||
std::vector<float> smoothedMagnitudes_;
|
||||
size_t bufferedSamples_;
|
||||
|
||||
void applyWindow(const float* input, float* output, size_t length);
|
||||
void pushSamples(const float* input, size_t length);
|
||||
};
|
||||
|
||||
} // namespace Visualizer
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "vectorscope.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace Visualizer {
|
||||
|
||||
Vectorscope::Vectorscope()
|
||||
: sampleRate_(48000.0f)
|
||||
, bufferSize_(1024)
|
||||
, writePos_(0)
|
||||
, validSamples_(0) {
|
||||
|
||||
leftBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||
rightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||
points_.reserve(1024);
|
||||
|
||||
// Cascaded lowpass at 8kHz, Butterworth (Q=0.707)
|
||||
// Two stages per channel = 4th order = 24 dB/oct rolloff
|
||||
// Removes HF noise that causes erratic Lissajous motion
|
||||
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
}
|
||||
|
||||
void Vectorscope::setSampleRate(float sampleRate) {
|
||||
sampleRate_ = sampleRate;
|
||||
// Redesign all filters with new sample rate
|
||||
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
}
|
||||
|
||||
void Vectorscope::setBufferSize(size_t size) {
|
||||
bufferSize_ = size;
|
||||
points_.reserve(size);
|
||||
}
|
||||
|
||||
void Vectorscope::pushSamples(
|
||||
const float* leftChannel,
|
||||
const float* rightChannel,
|
||||
size_t length
|
||||
) {
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
// Apply cascaded lowpass filtering
|
||||
float filteredL = leftLowpass1_.process(leftChannel[i]);
|
||||
filteredL = leftLowpass2_.process(filteredL);
|
||||
|
||||
float filteredR = rightLowpass1_.process(rightChannel[i]);
|
||||
filteredR = rightLowpass2_.process(filteredR);
|
||||
|
||||
leftBuffer_[writePos_] = filteredL;
|
||||
rightBuffer_[writePos_] = filteredR;
|
||||
|
||||
writePos_ = (writePos_ + 1) % VECTORSCOPE_BUFFER_SIZE;
|
||||
if (validSamples_ < VECTORSCOPE_BUFFER_SIZE) {
|
||||
validSamples_++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size_t Vectorscope::getPoints(float* xOut, float* yOut, size_t maxPoints) const {
|
||||
size_t count = std::min(maxPoints, validSamples_);
|
||||
|
||||
// Read the most recent `count` samples from the circular buffer
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
size_t idx = (writePos_ + VECTORSCOPE_BUFFER_SIZE - count + i) % VECTORSCOPE_BUFFER_SIZE;
|
||||
xOut[i] = rightBuffer_[idx]; // X = Right (standard Lissajous)
|
||||
yOut[i] = leftBuffer_[idx]; // Y = Left
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// Legacy process method (routes through new pipeline)
|
||||
const std::vector<VectorscopePoint>& Vectorscope::process(
|
||||
const float* leftChannel,
|
||||
const float* rightChannel,
|
||||
size_t length
|
||||
) {
|
||||
// Push through the filtering pipeline
|
||||
pushSamples(leftChannel, rightChannel, length);
|
||||
|
||||
// Build legacy output from buffer
|
||||
points_.clear();
|
||||
size_t count = std::min(length, validSamples_);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
size_t idx = (writePos_ + VECTORSCOPE_BUFFER_SIZE - count + i) % VECTORSCOPE_BUFFER_SIZE;
|
||||
VectorscopePoint p;
|
||||
p.x = rightBuffer_[idx];
|
||||
p.y = leftBuffer_[idx];
|
||||
points_.push_back(p);
|
||||
}
|
||||
return points_;
|
||||
}
|
||||
|
||||
void Vectorscope::reset() {
|
||||
writePos_ = 0;
|
||||
validSamples_ = 0;
|
||||
std::fill(leftBuffer_.begin(), leftBuffer_.end(), 0.0f);
|
||||
std::fill(rightBuffer_.begin(), rightBuffer_.end(), 0.0f);
|
||||
leftLowpass1_.reset();
|
||||
leftLowpass2_.reset();
|
||||
rightLowpass1_.reset();
|
||||
rightLowpass2_.reset();
|
||||
points_.clear();
|
||||
}
|
||||
|
||||
} // namespace Visualizer
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include "dsp_utils.h"
|
||||
#include <vector>
|
||||
#include <cstddef>
|
||||
|
||||
namespace Visualizer {
|
||||
|
||||
struct VectorscopePoint {
|
||||
float x; // Right channel
|
||||
float y; // Left channel
|
||||
};
|
||||
|
||||
// Circular buffer size (~170ms at 48kHz)
|
||||
constexpr size_t VECTORSCOPE_BUFFER_SIZE = 8192;
|
||||
|
||||
class Vectorscope {
|
||||
public:
|
||||
Vectorscope();
|
||||
|
||||
// Configuration
|
||||
void setSampleRate(float sampleRate);
|
||||
void setBufferSize(size_t size); // Legacy, kept for compat
|
||||
size_t getBufferSize() const { return bufferSize_; }
|
||||
|
||||
// Push stereo samples into circular buffer (called per worklet chunk)
|
||||
void pushSamples(const float* leftChannel, const float* rightChannel, size_t length);
|
||||
|
||||
// Get the most recent N points for rendering (from circular buffer)
|
||||
// Returns count of valid points written to output arrays
|
||||
size_t getPoints(float* xOut, float* yOut, size_t maxPoints) const;
|
||||
|
||||
// Get number of valid samples in buffer
|
||||
size_t getValidSamples() const { return validSamples_; }
|
||||
|
||||
// Legacy process (kept for backwards compatibility)
|
||||
const std::vector<VectorscopePoint>& process(
|
||||
const float* leftChannel,
|
||||
const float* rightChannel,
|
||||
size_t length
|
||||
);
|
||||
|
||||
// Reset state
|
||||
void reset();
|
||||
|
||||
private:
|
||||
float sampleRate_;
|
||||
size_t bufferSize_; // Legacy
|
||||
size_t writePos_;
|
||||
size_t validSamples_;
|
||||
|
||||
// Circular buffers for filtered L/R
|
||||
std::vector<float> leftBuffer_;
|
||||
std::vector<float> rightBuffer_;
|
||||
|
||||
// Cascaded lowpass filters (4th order Butterworth at 8kHz per channel)
|
||||
DSP::BiquadFilter leftLowpass1_;
|
||||
DSP::BiquadFilter leftLowpass2_;
|
||||
DSP::BiquadFilter rightLowpass1_;
|
||||
DSP::BiquadFilter rightLowpass2_;
|
||||
|
||||
// Legacy
|
||||
std::vector<VectorscopePoint> points_;
|
||||
};
|
||||
|
||||
} // namespace Visualizer
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.astrascope.AstraScopeModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { requireNativeModule, type NativeModule } from 'expo-modules-core';
|
||||
|
||||
/** Number of spectrum bins returned by getSpectrumFrame (fftSize/2, fftSize=2048). */
|
||||
export const SPECTRUM_BINS = 1024;
|
||||
|
||||
/** Spectrum values are dB magnitudes in this range (silence ~ -100). */
|
||||
export const SPECTRUM_DB_MIN = -100;
|
||||
export const SPECTRUM_DB_MAX = 0;
|
||||
|
||||
declare class AstraScopeModuleType extends NativeModule {
|
||||
/** Gate the audio-thread PCM tap. Off when backgrounded/paused/reduced-motion. */
|
||||
setActive(active: boolean): void;
|
||||
/**
|
||||
* Fill `out` (length should be {@link SPECTRUM_BINS}) with the latest dB
|
||||
* spectrum magnitudes in place; returns the number of bins written. Call once
|
||||
* per render frame from the JS thread.
|
||||
*/
|
||||
getSpectrumFrame(out: Float32Array): number;
|
||||
}
|
||||
|
||||
export const AstraScope = requireNativeModule<AstraScopeModuleType>('AstraScope');
|
||||
Generated
+93
@@ -15,6 +15,7 @@
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@op-engineering/op-sqlite": "^16.2.1",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
"encoding-japanese": "^2.2.0",
|
||||
"expo": "~56.0.4",
|
||||
"expo-asset": "~56.0.14",
|
||||
@@ -3025,6 +3026,38 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@shopify/react-native-skia": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.6.2.tgz",
|
||||
"integrity": "sha512-NzZ3+MRedZAUhguWw9DTCpWFd09Bq+tdGWhimGfJLGckuyoWGyimTiNTmaO2DeeivHTnGdv+eXbw7j/AV3LkRQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "0.41.0",
|
||||
"react-native-skia-android": "147.1.0",
|
||||
"react-native-skia-apple-ios": "147.1.0",
|
||||
"react-native-skia-apple-macos": "147.1.0",
|
||||
"react-native-skia-apple-tvos": "147.1.0",
|
||||
"react-reconciler": "0.31.0"
|
||||
},
|
||||
"bin": {
|
||||
"install-skia": "scripts/install-libs.js",
|
||||
"setup-skia-web": "scripts/setup-canvaskit.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19.0",
|
||||
"react-native": ">=0.78",
|
||||
"react-native-reanimated": ">=3.19.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-reanimated": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.10",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
|
||||
@@ -3817,6 +3850,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz",
|
||||
"integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
@@ -4577,6 +4616,15 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvaskit-wasm": {
|
||||
"version": "0.41.0",
|
||||
"resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.41.0.tgz",
|
||||
"integrity": "sha512-cnbL02NFB3yOYMF/MtxViZHgD1vh55Pvy+zR8q4JuFvyCPejZP3eClkt2GuZ0S7jOmGMCJXaHBasbMChbR9JZg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@webgpu/types": "0.1.21"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -10325,6 +10373,30 @@
|
||||
"react-native": ">=0.82.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-skia-android": {
|
||||
"version": "147.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-skia-android/-/react-native-skia-android-147.1.0.tgz",
|
||||
"integrity": "sha512-pWA0M0G74AhjEop0HLCkjWJMup2HJxOmuUjfPt6kSDhYeWKVx8AEzWh0Fh19ah78zE/s4hD0Of0Tyem5shhiTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-skia-apple-ios": {
|
||||
"version": "147.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-skia-apple-ios/-/react-native-skia-apple-ios-147.1.0.tgz",
|
||||
"integrity": "sha512-cr4rWe4Bf0H0TTutUp5cgHt5/Felttl1bh4BAAAsgAeL2F10FAK9urX8spjUshzMwjqXD7rNOWuFzU6ZcNlGKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-skia-apple-macos": {
|
||||
"version": "147.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-skia-apple-macos/-/react-native-skia-apple-macos-147.1.0.tgz",
|
||||
"integrity": "sha512-Qbv0Y7LgawtRKuGk8gnGeh8nDWwNiu03LcX0mVaQzBBbxFDYvqejanA+AkO3p8gsQb+fsXRc9DAk+U8cBnzZvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-skia-apple-tvos": {
|
||||
"version": "147.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-skia-apple-tvos/-/react-native-skia-apple-tvos-147.1.0.tgz",
|
||||
"integrity": "sha512-b+4vILXHPu++t8H41PHLBVsTab2LPqwXNdzgdScyl4+Cu8Ta34aUQW3T469cB0ogAMPdu//KV00w4YVpDZoRUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-svg": {
|
||||
"version": "15.15.4",
|
||||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz",
|
||||
@@ -10453,6 +10525,27 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.31.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz",
|
||||
"integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.25.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler/node_modules/scheduler": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@op-engineering/op-sqlite": "^16.2.1",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
"encoding-japanese": "^2.2.0",
|
||||
"expo": "~56.0.4",
|
||||
"expo-asset": "~56.0.14",
|
||||
|
||||
+14
-1
@@ -16,6 +16,7 @@ import {
|
||||
JetBrainsMono_500Medium,
|
||||
} from '@expo-google-fonts/jetbrains-mono';
|
||||
import { usePlaybackSync } from '@/audio/usePlaybackSync';
|
||||
import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { colors } from '@/theme';
|
||||
|
||||
@@ -27,6 +28,12 @@ function PlaybackSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Owns the visualizer on/off gate (foreground + playing + motion). Renders nothing. */
|
||||
function ScopeLifecycle() {
|
||||
useScopeLifecycle();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded] = useFonts({
|
||||
Inter_400Regular,
|
||||
@@ -59,6 +66,7 @@ export default function RootLayout() {
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="light" />
|
||||
<PlaybackSync />
|
||||
<ScopeLifecycle />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -68,7 +76,12 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen
|
||||
name="now-playing"
|
||||
options={{ presentation: 'modal', animation: 'slide_from_bottom' }}
|
||||
options={{
|
||||
presentation: 'transparentModal',
|
||||
animation: 'none',
|
||||
gestureEnabled: false,
|
||||
contentStyle: { backgroundColor: 'transparent' },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</SafeAreaProvider>
|
||||
|
||||
+242
-86
@@ -1,19 +1,42 @@
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import { View, Pressable, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
SlideInDown,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
import { Text } from '@/components/Text';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { FormatBadges } from '@/components/FormatBadge';
|
||||
import { SeekBar } from '@/components/SeekBar';
|
||||
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
||||
import { Visualizer } from '@/components/Visualizer';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { seekTo, skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
|
||||
|
||||
type IconName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
// Secondary controls are placeholders for now — laid out to settle the design.
|
||||
const SUB_CONTROLS: { icon: IconName; label: string }[] = [
|
||||
{ icon: 'shuffle', label: 'Shuffle' },
|
||||
{ icon: 'heart-outline', label: 'Favorite' },
|
||||
{ icon: 'list-outline', label: 'Queue' },
|
||||
{ icon: 'repeat', label: 'Repeat' },
|
||||
];
|
||||
|
||||
const DISMISS_DISTANCE = 140;
|
||||
const DISMISS_VELOCITY = 1000;
|
||||
|
||||
export default function NowPlayingScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const track = usePlayerStore((s) => s.currentTrack);
|
||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
@@ -21,103 +44,215 @@ export default function NowPlayingScreen() {
|
||||
|
||||
const isPlaying = playbackState === 'playing';
|
||||
const isLoading = playbackState === 'loading';
|
||||
const contentWidth = windowWidth - spacing.xl * 2;
|
||||
const artSize = Math.min(296, contentWidth);
|
||||
const source = track?.album?.trim() ? track.album : 'Library';
|
||||
|
||||
// Swipe down to minimize. The stack transition is disabled for this route, so
|
||||
// the sheet owns one continuous enter/exit animation instead of handing off to
|
||||
// a second native modal animation after release.
|
||||
const translateY = useSharedValue(0);
|
||||
const dismiss = () => router.back();
|
||||
|
||||
const dismissSheet = (velocity = 0) => {
|
||||
translateY.value = withSpring(
|
||||
windowHeight,
|
||||
{
|
||||
damping: 28,
|
||||
stiffness: 240,
|
||||
velocity,
|
||||
overshootClamping: true,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) runOnJS(dismiss)();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.activeOffsetY(14) // engage only on a downward drag
|
||||
.failOffsetY(-14)
|
||||
.failOffsetX([-24, 24]) // let the horizontal seek drag through
|
||||
.onUpdate((e) => {
|
||||
translateY.value = e.translationY > 0 ? e.translationY : 0;
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY) {
|
||||
translateY.value = withSpring(
|
||||
windowHeight,
|
||||
{
|
||||
damping: 28,
|
||||
stiffness: 240,
|
||||
velocity: e.velocityY,
|
||||
overshootClamping: true,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) runOnJS(dismiss)();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
translateY.value = withSpring(0, { damping: 20, stiffness: 220 });
|
||||
}
|
||||
});
|
||||
|
||||
const contentStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.root,
|
||||
{ paddingTop: insets.top + spacing.sm, paddingBottom: insets.bottom + spacing.xl },
|
||||
]}
|
||||
>
|
||||
<Pressable style={styles.close} onPress={() => router.back()} hitSlop={12}>
|
||||
<Ionicons name="chevron-down" size={28} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
{track ? (
|
||||
<>
|
||||
<View style={styles.artWrap}>
|
||||
<View style={styles.art}>
|
||||
{track.artworkData ? (
|
||||
<Image source={{ uri: track.artworkData }} style={styles.artImage} contentFit="cover" />
|
||||
) : (
|
||||
<AstraLogo size={104} />
|
||||
)}
|
||||
<View style={styles.backdrop}>
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(240)}
|
||||
style={[
|
||||
styles.content,
|
||||
contentStyle,
|
||||
{ paddingTop: insets.top + spacing.sm, paddingBottom: insets.bottom + spacing.lg },
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
|
||||
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View style={styles.headerMid}>
|
||||
<Text variant="caption" style={styles.eyebrow}>
|
||||
PLAYING FROM
|
||||
</Text>
|
||||
<Text variant="label" numberOfLines={1} style={styles.source}>
|
||||
{source}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable style={styles.headerBtn} hitSlop={12} accessibilityLabel="More options">
|
||||
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.meta}>
|
||||
<Text variant="heading" numberOfLines={2}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.textSecondary}
|
||||
numberOfLines={1}
|
||||
style={styles.subtitle}
|
||||
>
|
||||
{track.artist}
|
||||
{track.album ? ` · ${track.album}` : ''}
|
||||
</Text>
|
||||
<View style={styles.badges}>
|
||||
<FormatBadges track={track} />
|
||||
{track ? (
|
||||
<>
|
||||
<View style={styles.artWrap}>
|
||||
<View style={[styles.art, { width: artSize, height: artSize }]}>
|
||||
{track.artworkData ? (
|
||||
<Image
|
||||
source={{ uri: track.artworkData }}
|
||||
style={styles.artImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<AstraLogo size={Math.round(artSize * 0.4)} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Visualizer width={contentWidth} />
|
||||
|
||||
<View style={styles.trackInfo}>
|
||||
<Text variant="heading" numberOfLines={2} style={styles.centered}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text variant="body" numberOfLines={1} style={[styles.centered, styles.artist]}>
|
||||
{track.artist}
|
||||
</Text>
|
||||
<View style={styles.badges}>
|
||||
<FormatBadges track={track} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBlock}>
|
||||
<WaveformSeekBar
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
trackKey={track.id}
|
||||
trackPath={track.path}
|
||||
onSeek={(seconds) => void seekTo(seconds)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
|
||||
<View style={styles.transport}>
|
||||
<Pressable onPress={skipToPrevious} hitSlop={12}>
|
||||
<Ionicons name="play-skip-back" size={32} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={togglePlay} hitSlop={12} style={styles.playButton}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={34}
|
||||
color={colors.bgPrimary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={skipToNext} hitSlop={12}>
|
||||
<Ionicons name="play-skip-forward" size={32} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.subRow}>
|
||||
{SUB_CONTROLS.map((c) => (
|
||||
<Pressable
|
||||
key={c.label}
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
accessibilityLabel={c.label}
|
||||
>
|
||||
<Ionicons name={c.icon} size={20} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.empty}>
|
||||
<Text variant="heading">Nothing playing</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.centered}>
|
||||
Start a track from Home.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBlock}>
|
||||
<SeekBar
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
trackKey={track.id}
|
||||
onSeek={(seconds) => void seekTo(seconds)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.transport}>
|
||||
<Pressable onPress={skipToPrevious} hitSlop={12}>
|
||||
<Ionicons name="play-skip-back" size={34} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={togglePlay} hitSlop={12} style={styles.playButton}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={36}
|
||||
color={colors.bgPrimary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={skipToNext} hitSlop={12}>
|
||||
<Ionicons name="play-skip-forward" size={34} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.empty}>
|
||||
<Text variant="heading">Nothing playing</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.subtitle}>
|
||||
Start a track from Home.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
close: {
|
||||
alignSelf: 'flex-start',
|
||||
padding: spacing.xs,
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
artWrap: {
|
||||
flex: 1,
|
||||
headerBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerMid: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
eyebrow: {
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: 1.5,
|
||||
fontSize: 10,
|
||||
},
|
||||
source: {
|
||||
color: colors.textSecondary,
|
||||
marginTop: 1,
|
||||
},
|
||||
artWrap: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
art: {
|
||||
width: 260,
|
||||
height: 260,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
alignItems: 'center',
|
||||
@@ -128,33 +263,54 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
meta: {
|
||||
marginTop: spacing.xl,
|
||||
trackInfo: {
|
||||
marginTop: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
centered: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
artist: {
|
||||
color: colors.accentText,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
badges: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
progressBlock: {
|
||||
marginTop: spacing.md,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
minHeight: spacing.md,
|
||||
},
|
||||
transport: {
|
||||
marginTop: spacing.xl,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xxl,
|
||||
},
|
||||
playButton: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
width: 68,
|
||||
height: 68,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
subRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
subBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import { useState } from 'react';
|
||||
import { View, Pressable, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Text } from './Text';
|
||||
import { AstraLogo } from './AstraLogo';
|
||||
import { colors, layout, radius, spacing } from '@/theme';
|
||||
import { SpectrumCurve } from './SpectrumCurve';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { togglePlay } from '@/audio/playbackController';
|
||||
import { skipToNext, togglePlay } from '@/audio/playbackController';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
|
||||
|
||||
const PILL_HEIGHT = 56;
|
||||
const ART = 42;
|
||||
const CURVE_POINTS = 64;
|
||||
|
||||
/**
|
||||
* Persistent mini-player, rendered above the tab bar. Tapping the bar opens the
|
||||
* full now-playing screen. The artwork box is where the spectrum "pulse"
|
||||
* is-playing indicator will live at M3.
|
||||
* Persistent floating mini-player (M3 redesign): a rounded pill above the tab
|
||||
* bar with the live filled-line spectrum drifting behind the metadata. Tapping
|
||||
* opens the full now-playing screen.
|
||||
*/
|
||||
export function MiniPlayer() {
|
||||
const router = useRouter();
|
||||
@@ -20,24 +28,38 @@ export function MiniPlayer() {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
|
||||
const scopeActive = useScopeActive();
|
||||
const values = useSpectrumCurve(CURVE_POINTS, scopeActive);
|
||||
const [pillWidth, setPillWidth] = useState(0);
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
const isPlaying = playbackState === 'playing';
|
||||
const isLoading = playbackState === 'loading';
|
||||
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
|
||||
</View>
|
||||
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
|
||||
|
||||
<Pressable style={styles.row} onPress={() => router.push('/now-playing')}>
|
||||
return (
|
||||
<Pressable style={styles.pill} onPress={() => router.push('/now-playing')} onLayout={onLayout}>
|
||||
{scopeActive && pillWidth > 0 && (
|
||||
<View pointerEvents="none" style={styles.spectrum}>
|
||||
<SpectrumCurve
|
||||
values={values}
|
||||
width={pillWidth}
|
||||
height={PILL_HEIGHT}
|
||||
lineWidth={1.5}
|
||||
fillOpacity={0.5}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.row}>
|
||||
<View style={styles.art}>
|
||||
{track.artworkData ? (
|
||||
<Image source={{ uri: track.artworkData }} style={styles.artImage} contentFit="cover" />
|
||||
) : (
|
||||
<AstraLogo size={22} />
|
||||
<AstraLogo size={20} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -50,45 +72,56 @@ export function MiniPlayer() {
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable hitSlop={12} onPress={togglePlay} style={styles.playButton}>
|
||||
<Pressable hitSlop={10} onPress={togglePlay} style={styles.control}>
|
||||
<Ionicons
|
||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={26}
|
||||
size={24}
|
||||
color={colors.accent}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable hitSlop={10} onPress={skipToNext} style={styles.control}>
|
||||
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: layout.miniPlayerHeight,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
borderTopColor: colors.glassBorder,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
pill: {
|
||||
height: PILL_HEIGHT,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
borderColor: colors.glassBorder,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
progressTrack: {
|
||||
height: 2,
|
||||
backgroundColor: colors.glassBorder,
|
||||
},
|
||||
progressFill: {
|
||||
height: 2,
|
||||
backgroundColor: colors.accent,
|
||||
spectrum: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
art: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
width: ART,
|
||||
height: ART,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
@@ -103,12 +136,24 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 15,
|
||||
},
|
||||
playButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
control: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
progressTrack: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 2,
|
||||
backgroundColor: colors.glassBorder,
|
||||
},
|
||||
progressFill: {
|
||||
height: 2,
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
});
|
||||
|
||||
export default MiniPlayer;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Canvas, Group, LinearGradient, Path, Skia, vec } from '@shopify/react-native-skia';
|
||||
import { colors } from '@/theme';
|
||||
|
||||
interface SpectrumCurveProps {
|
||||
/** Normalized magnitudes in [0,1], one per point (see useSpectrumCurve). */
|
||||
values: number[];
|
||||
width: number;
|
||||
height: number;
|
||||
/** Hex line/fill color (e.g. theme accent). Defaults to the cyan accent. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** #rrggbb -> rgba() with the given alpha. */
|
||||
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})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const line = Skia.Path.Make();
|
||||
const n = values.length;
|
||||
if (n < 2 || width <= 0 || height <= 0) return { line, fill: line.copy() };
|
||||
|
||||
const usableH = height - pad * 2;
|
||||
const xAt = (i: number) => (i / (n - 1)) * width;
|
||||
const yAt = (i: number) => {
|
||||
const v = values[i] < 0 ? 0 : values[i] > 1 ? 1 : values[i];
|
||||
return pad + (1 - v) * usableH;
|
||||
};
|
||||
|
||||
line.moveTo(xAt(0), yAt(0));
|
||||
for (let i = 1; i < n; i++) {
|
||||
const midX = (xAt(i - 1) + xAt(i)) * 0.5;
|
||||
const midY = (yAt(i - 1) + yAt(i)) * 0.5;
|
||||
line.quadTo(xAt(i - 1), yAt(i - 1), midX, midY);
|
||||
}
|
||||
line.lineTo(xAt(n - 1), yAt(n - 1));
|
||||
|
||||
const fill = line.copy();
|
||||
fill.lineTo(width, height);
|
||||
fill.lineTo(0, height);
|
||||
fill.close();
|
||||
|
||||
return { line, fill };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled-line spectrum (the desktop "CURVE" look): a smooth line over a vertical
|
||||
* gradient fill. Source-agnostic — give it normalized values and a size.
|
||||
*/
|
||||
export function SpectrumCurve({
|
||||
values,
|
||||
width,
|
||||
height,
|
||||
color = colors.accent,
|
||||
lineWidth = 2,
|
||||
fillOpacity = 1,
|
||||
glow = false,
|
||||
}: SpectrumCurveProps) {
|
||||
const pad = lineWidth;
|
||||
const { line, fill } = useMemo(
|
||||
() => buildPaths(values, width, height, pad),
|
||||
[values, width, height, pad]
|
||||
);
|
||||
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
|
||||
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}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpectrumCurve;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from './Text';
|
||||
import { SpectrumCurve } from './SpectrumCurve';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
|
||||
|
||||
const CANVAS_HEIGHT = 96;
|
||||
const POINTS = 120;
|
||||
|
||||
type Mode = 'spectrum' | 'scope';
|
||||
|
||||
/**
|
||||
* Inline visualizer for the now-playing screen — no card chrome, it just lives
|
||||
* in the layout. Tap anywhere on it to switch between the live filled-line
|
||||
* Spectrum and the Scope (oscilloscope, placeholder until its native path lands).
|
||||
*/
|
||||
export function Visualizer({ width }: { width: number }) {
|
||||
const [mode, setMode] = useState<Mode>('spectrum');
|
||||
const scopeActive = useScopeActive();
|
||||
const spectrumActive = scopeActive && mode === 'spectrum';
|
||||
const values = useSpectrumCurve(POINTS, spectrumActive);
|
||||
|
||||
const toggle = () => setMode((m) => (m === 'spectrum' ? 'scope' : 'spectrum'));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={toggle}
|
||||
style={[styles.wrap, { width }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Visualizer showing ${mode}. Tap to switch.`}
|
||||
>
|
||||
<View style={styles.caption}>
|
||||
<Text variant="caption" style={styles.captionText}>
|
||||
{mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
|
||||
</Text>
|
||||
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
|
||||
</View>
|
||||
|
||||
<View style={{ width, height: CANVAS_HEIGHT }}>
|
||||
{mode === 'spectrum' ? (
|
||||
<SpectrumCurve values={values} width={width} height={CANVAS_HEIGHT} glow />
|
||||
) : (
|
||||
<View style={styles.placeholder}>
|
||||
<Ionicons name="pulse-outline" size={20} color={colors.textTertiary} />
|
||||
<Text variant="caption" style={styles.placeholderText}>
|
||||
OSCILLOSCOPE · COMING SOON
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
caption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
captionText: {
|
||||
color: colors.textTertiary,
|
||||
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;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native';
|
||||
import { Canvas, Group, Path, Skia, rect } from '@shopify/react-native-skia';
|
||||
import { Text } from './Text';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { downsampleWaveform, getWaveform } from '@/scope/waveform';
|
||||
|
||||
const CANVAS_HEIGHT = 58;
|
||||
const BAR_WIDTH = 3;
|
||||
const BAR_GAP = 2;
|
||||
const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver
|
||||
// While a seek is pending, keep showing the target until the player's reported
|
||||
// position moves off the pre-seek value (`from`) — i.e. the seek has landed.
|
||||
const HOLD_EPS = 0.75;
|
||||
|
||||
interface WaveformSeekBarProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (seconds: number) => void;
|
||||
/** Identity of the playing track; a pending seek only applies to its own track. */
|
||||
trackKey?: string | number;
|
||||
/** Track file URI used to load/cache the offline waveform peaks. */
|
||||
trackPath?: string;
|
||||
}
|
||||
|
||||
const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction));
|
||||
|
||||
/**
|
||||
* Waveform seek bar (M3) — ports desktop WaveformSeekBar's look (RMS bars, a
|
||||
* played/unplayed split, draggable playhead) on Skia, while keeping SeekBar's
|
||||
* tap/drag + pending-seek "hold" state machine verbatim so seeking behaves
|
||||
* identically. Peaks load offline (getWaveform) and fall back to flat bars.
|
||||
*/
|
||||
export function WaveformSeekBar({
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
trackKey,
|
||||
trackPath,
|
||||
}: WaveformSeekBarProps) {
|
||||
const [scrubFraction, setScrubFraction] = useState<number | null>(null);
|
||||
const [barWidth, setBarWidth] = useState(0);
|
||||
const [pendingSeek, setPendingSeek] = useState<{
|
||||
target: number;
|
||||
from: number;
|
||||
key?: string | number;
|
||||
} | null>(null);
|
||||
// Peaks tagged with the path they belong to, so a track change drops the old
|
||||
// waveform as a pure derivation (no synchronous setState in the effect).
|
||||
const [loaded, setLoaded] = useState<{ path: string; peaks: Float32Array | null } | null>(null);
|
||||
|
||||
const widthRef = useRef(0);
|
||||
const scrubRef = useRef<number | null>(null);
|
||||
const grantRef = useRef({ fraction: 0, pageX: 0 });
|
||||
|
||||
// Load (cache-first) the offline peaks whenever the track changes.
|
||||
useEffect(() => {
|
||||
if (!trackPath) return;
|
||||
let cancelled = false;
|
||||
void getWaveform(trackPath).then((peaks) => {
|
||||
if (!cancelled) setLoaded({ path: trackPath, peaks });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trackPath]);
|
||||
|
||||
const source = loaded && loaded.path === trackPath ? loaded.peaks : null;
|
||||
|
||||
const setScrub = (fraction: number | null) => {
|
||||
scrubRef.current = fraction;
|
||||
setScrubFraction(fraction);
|
||||
};
|
||||
|
||||
const onLayout = (event: LayoutChangeEvent) => {
|
||||
widthRef.current = event.nativeEvent.layout.width;
|
||||
setBarWidth(event.nativeEvent.layout.width);
|
||||
};
|
||||
|
||||
const handleGrant = (event: GestureResponderEvent) => {
|
||||
const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current));
|
||||
grantRef.current = { fraction, pageX: event.nativeEvent.pageX };
|
||||
setScrub(fraction);
|
||||
};
|
||||
|
||||
const handleMove = (event: GestureResponderEvent) => {
|
||||
const delta = (event.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current);
|
||||
setScrub(clamp(grantRef.current.fraction + delta));
|
||||
};
|
||||
|
||||
const handleRelease = () => {
|
||||
const fraction = scrubRef.current ?? grantRef.current.fraction;
|
||||
const target = fraction * duration;
|
||||
// Capture the pre-seek position so we can hold the target until the player
|
||||
// moves off it. Using `from` (not the target) means the hold releases when
|
||||
// the seek lands and can never re-engage as playback advances past target.
|
||||
setPendingSeek({ target, from: currentTime, key: trackKey });
|
||||
onSeek(target);
|
||||
setScrub(null);
|
||||
};
|
||||
|
||||
// Displayed position: scrub > held seek target > live progress. Hold while the
|
||||
// player still reports the stale pre-seek position; release once it jumps.
|
||||
const holdSeek =
|
||||
pendingSeek != null &&
|
||||
pendingSeek.key === trackKey &&
|
||||
duration > 0 &&
|
||||
Math.abs(currentTime - pendingSeek.from) < HOLD_EPS;
|
||||
const liveFraction = duration > 0 ? Math.min(1, currentTime / duration) : 0;
|
||||
const heldFraction = holdSeek ? clamp(pendingSeek.target / duration) : null;
|
||||
const fraction = scrubFraction ?? heldFraction ?? liveFraction;
|
||||
const shownTime = fraction * duration;
|
||||
|
||||
const barCount = Math.max(1, Math.floor(barWidth / (BAR_WIDTH + BAR_GAP)));
|
||||
|
||||
// Build one Skia path of all bars (rounded rects). Drawn twice with a clip
|
||||
// split at the playhead: played in accent, unplayed in glassBorder.
|
||||
const barsPath = useMemo(() => {
|
||||
const path = Skia.Path.Make();
|
||||
if (barWidth <= 0) return path;
|
||||
const display = source
|
||||
? downsampleWaveform(source, barCount)
|
||||
: new Float32Array(barCount).fill(MIN_BAR);
|
||||
const r = BAR_WIDTH / 2;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const amp = Math.max(MIN_BAR, display[i] ?? MIN_BAR);
|
||||
const h = amp * CANVAS_HEIGHT;
|
||||
const x = i * (BAR_WIDTH + BAR_GAP);
|
||||
const y = (CANVAS_HEIGHT - h) / 2;
|
||||
path.addRRect(Skia.RRectXY(Skia.XYWHRect(x, y, BAR_WIDTH, h), r, r));
|
||||
}
|
||||
return path;
|
||||
}, [source, barCount, barWidth]);
|
||||
|
||||
const splitX = fraction * barWidth;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
style={styles.touchArea}
|
||||
onLayout={onLayout}
|
||||
onStartShouldSetResponder={() => duration > 0}
|
||||
onMoveShouldSetResponder={() => duration > 0}
|
||||
onResponderTerminationRequest={() => false}
|
||||
onResponderGrant={handleGrant}
|
||||
onResponderMove={handleMove}
|
||||
onResponderRelease={handleRelease}
|
||||
onResponderTerminate={() => setScrub(null)}
|
||||
accessibilityRole="adjustable"
|
||||
accessibilityLabel="Seek"
|
||||
accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }}
|
||||
>
|
||||
<Canvas style={{ width: '100%', height: CANVAS_HEIGHT }}>
|
||||
<Group clip={rect(0, 0, splitX, CANVAS_HEIGHT)}>
|
||||
<Path path={barsPath} color={colors.accent} />
|
||||
</Group>
|
||||
<Group clip={rect(splitX, 0, Math.max(0, barWidth - splitX), CANVAS_HEIGHT)}>
|
||||
<Path path={barsPath} color={colors.glassBorder} />
|
||||
</Group>
|
||||
</Canvas>
|
||||
</View>
|
||||
<View style={styles.times}>
|
||||
<Text variant="mono" style={[styles.time, scrubFraction != null && styles.timeActive]}>
|
||||
{formatDuration(shownTime)}
|
||||
</Text>
|
||||
<Text variant="mono" style={styles.time}>
|
||||
{formatDuration(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
touchArea: {
|
||||
justifyContent: 'center',
|
||||
height: CANVAS_HEIGHT + spacing.md * 2, // generous touch target around the canvas
|
||||
},
|
||||
times: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
time: {
|
||||
color: colors.textTertiary,
|
||||
fontSize: 13,
|
||||
},
|
||||
timeActive: {
|
||||
color: colors.accentText,
|
||||
},
|
||||
});
|
||||
|
||||
export default WaveformSeekBar;
|
||||
+27
-2
@@ -2,11 +2,13 @@
|
||||
// src/main/services/library.ts). v1 covers M1 (local scan + browse);
|
||||
// v2 adds playlists + favorites (M2); v3 forces re-extraction of tracks whose
|
||||
// non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts);
|
||||
// v4 adds a key-value settings table (artist grouping mode, future prefs).
|
||||
// v4 adds a key-value settings table (artist grouping mode, future prefs);
|
||||
// v5 caches offline waveform peaks for the M3 waveform seek bar; v6 repairs DBs
|
||||
// that an abandoned earlier M3 spike left at v5 with a stale `waveform_cache`.
|
||||
|
||||
import type { LibraryDatabase } from './database';
|
||||
|
||||
export const SCHEMA_VERSION = 4;
|
||||
export const SCHEMA_VERSION = 6;
|
||||
|
||||
// One statement per entry — op-sqlite executes single statements.
|
||||
const MIGRATIONS: readonly (readonly string[])[] = [
|
||||
@@ -90,6 +92,29 @@ const MIGRATIONS: readonly (readonly string[])[] = [
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
],
|
||||
// v4 -> v5 — cached waveform peaks (offline RMS bins) for the seek bar.
|
||||
// Keyed by track path (SAF URI), no FK — survives folder removal/re-grant
|
||||
// like favorites/playlists. `peaks` is a tightly-packed Float32 LE blob.
|
||||
[
|
||||
`CREATE TABLE IF NOT EXISTS waveform_peaks (
|
||||
track_path TEXT PRIMARY KEY NOT NULL,
|
||||
bins INTEGER NOT NULL,
|
||||
peaks BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
],
|
||||
// v5 -> v6 — repair: an abandoned earlier M3 spike shipped a v5 that created a
|
||||
// different `waveform_cache` table, leaving such DBs at v5 without the
|
||||
// `waveform_peaks` table above. Create it if missing and drop the orphan.
|
||||
[
|
||||
`CREATE TABLE IF NOT EXISTS waveform_peaks (
|
||||
track_path TEXT PRIMARY KEY NOT NULL,
|
||||
bins INTEGER NOT NULL,
|
||||
peaks BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)`,
|
||||
`DROP TABLE IF EXISTS waveform_cache`,
|
||||
],
|
||||
];
|
||||
|
||||
export async function migrate(db: LibraryDatabase): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Waveform peak cache — offline RMS bins for the M3 waveform seek bar.
|
||||
// Keyed by track path (SAF URI), mirroring favorites/playlists (no FK, so a row
|
||||
// survives folder removal and resolves again on re-grant). Peaks are normalized
|
||||
// to [0, 1] and stored as a tightly-packed Float32 little-endian blob.
|
||||
|
||||
import type { LibraryDatabase } from './database';
|
||||
|
||||
export async function getWaveformPeaks(
|
||||
db: LibraryDatabase,
|
||||
trackPath: string
|
||||
): Promise<Float32Array | null> {
|
||||
const row = await db.get<{ peaks: ArrayBuffer | ArrayBufferView }>(
|
||||
'SELECT peaks FROM waveform_peaks WHERE track_path = ?',
|
||||
[trackPath]
|
||||
);
|
||||
return row ? toFloat32(row.peaks) : null;
|
||||
}
|
||||
|
||||
export async function putWaveformPeaks(
|
||||
db: LibraryDatabase,
|
||||
trackPath: string,
|
||||
peaks: Float32Array
|
||||
): Promise<void> {
|
||||
// Bind the typed-array view directly (a valid ArrayBufferView Scalar); copy to
|
||||
// a tight view first if it's a window into a larger buffer.
|
||||
const tight =
|
||||
peaks.byteOffset === 0 && peaks.byteLength === peaks.buffer.byteLength
|
||||
? peaks
|
||||
: peaks.slice();
|
||||
await db.run(
|
||||
`INSERT INTO waveform_peaks (track_path, bins, peaks, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(track_path) DO UPDATE SET
|
||||
bins = excluded.bins, peaks = excluded.peaks, created_at = excluded.created_at`,
|
||||
[trackPath, peaks.length, tight, Date.now()]
|
||||
);
|
||||
}
|
||||
|
||||
function toFloat32(blob: ArrayBuffer | ArrayBufferView): Float32Array {
|
||||
if (blob instanceof Float32Array) return blob;
|
||||
if (ArrayBuffer.isView(blob)) {
|
||||
return new Float32Array(blob.buffer, blob.byteOffset, Math.floor(blob.byteLength / 4));
|
||||
}
|
||||
return new Float32Array(blob);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Whether the visualizers should run. Set by useScopeLifecycle (foreground +
|
||||
* playing + not reduced-motion) and read by the scope components so they only
|
||||
* spin their frame loop when something is actually visible and moving.
|
||||
*/
|
||||
interface ScopeStore {
|
||||
active: boolean;
|
||||
setActive: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export const useScopeStore = create<ScopeStore>((set) => ({
|
||||
active: false,
|
||||
setActive: (active) => set({ active }),
|
||||
}));
|
||||
|
||||
export const useScopeActive = (): boolean => useScopeStore((s) => s.active);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AccessibilityInfo, AppState } from 'react-native';
|
||||
import { AstraScope } from '../../modules/astra-scope';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useScopeStore } from './scopeStore';
|
||||
|
||||
/**
|
||||
* Single owner of the scope on/off gate. Visualizers run only when the app is
|
||||
* foregrounded, audio is playing, and reduced-motion is off — which also stops
|
||||
* the native PCM tap (AstraScope.setActive) so a backgrounded/paused app pays
|
||||
* ~nothing in the audio callback. Mount once near the root.
|
||||
*/
|
||||
export function useScopeLifecycle(): void {
|
||||
useEffect(() => {
|
||||
let reduceMotion = false;
|
||||
let appActive = AppState.currentState === 'active';
|
||||
|
||||
const recompute = () => {
|
||||
const playing = usePlayerStore.getState().playbackState === 'playing';
|
||||
const on = playing && appActive && !reduceMotion;
|
||||
AstraScope.setActive(on);
|
||||
useScopeStore.getState().setActive(on);
|
||||
};
|
||||
|
||||
const appSub = AppState.addEventListener('change', (state) => {
|
||||
appActive = state === 'active';
|
||||
recompute();
|
||||
});
|
||||
const rmSub = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled) => {
|
||||
reduceMotion = enabled;
|
||||
recompute();
|
||||
});
|
||||
const unsubPlayer = usePlayerStore.subscribe(recompute);
|
||||
void AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
|
||||
reduceMotion = enabled;
|
||||
recompute();
|
||||
});
|
||||
recompute();
|
||||
|
||||
return () => {
|
||||
appSub.remove();
|
||||
rmSub.remove();
|
||||
unsubPlayer();
|
||||
AstraScope.setActive(false);
|
||||
useScopeStore.getState().setActive(false);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Waveform peaks for the seek bar: cache-first, decode-on-miss, store. The heavy
|
||||
// native decode (AstraLibraryScanner.extractWaveform) runs once per track and the
|
||||
// result is cached in SQLite; downsampleWaveform shapes the cached high-res peaks
|
||||
// to the display's bar count at render time (ported from desktop waveformExtractor).
|
||||
|
||||
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getWaveformPeaks, putWaveformPeaks } from '@/db/waveformQueries';
|
||||
|
||||
export const WAVEFORM_BINS = 512;
|
||||
|
||||
// Dedupe concurrent requests for the same track (e.g. mini-player + now-playing).
|
||||
const inflight = new Map<string, Promise<Float32Array | null>>();
|
||||
|
||||
export function getWaveform(trackPath: string): Promise<Float32Array | null> {
|
||||
const existing = inflight.get(trackPath);
|
||||
if (existing) return existing;
|
||||
const task = loadWaveform(trackPath).finally(() => inflight.delete(trackPath));
|
||||
inflight.set(trackPath, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function loadWaveform(trackPath: string): Promise<Float32Array | null> {
|
||||
const db = await openLibraryDb();
|
||||
const cached = await getWaveformPeaks(db, trackPath);
|
||||
if (cached && cached.length > 0) return cached;
|
||||
|
||||
let raw: number[];
|
||||
try {
|
||||
raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || raw.length === 0) return null;
|
||||
|
||||
const peaks = Float32Array.from(raw);
|
||||
await putWaveformPeaks(db, trackPath, peaks).catch(() => {
|
||||
/* cache write failure is non-fatal */
|
||||
});
|
||||
return peaks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downsample high-res peaks to `barCount` bars with a power curve and two
|
||||
* smoothing passes. Ported verbatim from desktop waveformExtractor.ts so the
|
||||
* mobile seek bar matches the desktop look.
|
||||
*/
|
||||
export function downsampleWaveform(source: Float32Array, barCount: number): Float32Array {
|
||||
if (source.length === 0 || barCount <= 0) return new Float32Array(0);
|
||||
const binsPerBar = source.length / barCount;
|
||||
const peaks = new Float32Array(barCount);
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const start = Math.floor(i * binsPerBar);
|
||||
const end = Math.max(start + 1, Math.floor((i + 1) * binsPerBar));
|
||||
let sum = 0;
|
||||
for (let j = start; j < end; j++) sum += source[j];
|
||||
peaks[i] = sum / (end - start);
|
||||
}
|
||||
|
||||
let max = 0;
|
||||
for (let i = 0; i < barCount; i++) if (peaks[i] > max) max = peaks[i];
|
||||
if (max > 0) for (let i = 0; i < barCount; i++) peaks[i] /= max;
|
||||
|
||||
// Power curve — exaggerate dynamic range.
|
||||
for (let i = 0; i < barCount; i++) peaks[i] = peaks[i] ** 2;
|
||||
|
||||
// Two smoothing passes.
|
||||
let current = peaks;
|
||||
for (let p = 0; p < 2; p++) {
|
||||
const smoothed = new Float32Array(current.length);
|
||||
smoothed[0] = current[0];
|
||||
smoothed[current.length - 1] = current[current.length - 1];
|
||||
for (let i = 1; i < current.length - 1; i++) {
|
||||
smoothed[i] = current[i - 1] * 0.25 + current[i] * 0.5 + current[i + 1] * 0.25;
|
||||
}
|
||||
current = smoothed;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
+21
-20
@@ -1,32 +1,33 @@
|
||||
/**
|
||||
* Astra color tokens — ported from desktop `src/renderer/styles/globals.css`.
|
||||
* Dark-only on mobile (the desktop app is dark-only too).
|
||||
* Astra color tokens. Dark-only. M3 redesign shifted the palette from
|
||||
* cyan-on-black toward a softer indigo-on-navy "mobile-first" language; these
|
||||
* tokens are the single source of truth, so a future theming pass can swap them.
|
||||
*/
|
||||
export const colors = {
|
||||
// Base backgrounds
|
||||
bgPrimary: '#000000',
|
||||
bgSecondary: '#050505',
|
||||
bgTertiary: '#0a0a0a',
|
||||
// Base backgrounds (navy)
|
||||
bgPrimary: '#080a0f',
|
||||
bgSecondary: '#0c0f18',
|
||||
bgTertiary: '#11162a',
|
||||
|
||||
// Glass / surface overlays (white alphas)
|
||||
glassBg: 'rgba(255, 255, 255, 0.03)',
|
||||
glassBorder: 'rgba(255, 255, 255, 0.08)',
|
||||
glassHighlight: 'rgba(255, 255, 255, 0.05)',
|
||||
// Glass / surface overlays (subtle blue-tinted alphas)
|
||||
glassBg: 'rgba(124, 146, 196, 0.05)',
|
||||
glassBorder: 'rgba(124, 146, 196, 0.16)',
|
||||
glassHighlight: 'rgba(140, 162, 208, 0.08)',
|
||||
|
||||
// Text (white alphas)
|
||||
textPrimary: 'rgba(255, 255, 255, 0.95)',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.6)',
|
||||
textTertiary: 'rgba(255, 255, 255, 0.4)',
|
||||
// Text (blue-tinted neutrals)
|
||||
textPrimary: '#e2e8f4',
|
||||
textSecondary: '#8a98b8',
|
||||
textTertiary: '#52607f',
|
||||
|
||||
// Warning amber (desktop .graph-meta-chip-warning)
|
||||
warning: '#f3d27d',
|
||||
|
||||
// Cyan accent
|
||||
accent: '#38bdf8',
|
||||
accentHover: '#7dd3fc',
|
||||
accentGlow: 'rgba(56, 189, 248, 0.3)',
|
||||
accentText: '#bae6fd',
|
||||
accentTextStrong: '#e0f2fe',
|
||||
// Indigo accent
|
||||
accent: '#5b8aff',
|
||||
accentHover: '#82a6ff',
|
||||
accentGlow: 'rgba(91, 138, 255, 0.3)',
|
||||
accentText: '#a9c0ff',
|
||||
accentTextStrong: '#d6e2ff',
|
||||
|
||||
// Astra mark fills (hsl(198 …) from the desktop logo)
|
||||
logoMain: '#00b3ff', // hsl(198 100% 50%)
|
||||
|
||||
Vendored
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2022 Double Symmetry GmbH
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Vendored fork of com.github.doublesymmetry:kotlinaudio v2.1.0 (Apache-2.0).
|
||||
// Substituted in for the Jitpack binary so we can inject a PCM-tap AudioProcessor
|
||||
// into the ExoPlayer it builds (see players/BaseAudioPlayer.kt + scope/). The
|
||||
// ONLY source change vs upstream v2.1.0 is the single .setRenderersFactory(...)
|
||||
// line in BaseAudioPlayer's init and the new scope/ package. Keep that diff
|
||||
// minimal so re-vendoring on a kotlin-audio bump stays mechanical.
|
||||
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.doublesymmetry.kotlinaudio"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'io.coil-kt:coil:2.2.0'
|
||||
implementation 'androidx.media:media:1.6.0'
|
||||
api 'com.google.android.exoplayer:exoplayer:2.19.0'
|
||||
api 'com.google.android.exoplayer:extension-mediasession:2.19.0'
|
||||
api 'com.jakewharton.timber:timber:5.0.1'
|
||||
|
||||
// The PCM tap forwards to expo.modules.astrascope.ScopeBridge.
|
||||
implementation project(':astra-scope')
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
o/classes
|
||||
BIN
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
o/bundleLibRuntimeToDirDebug
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user