m3, ui/ux, and more

This commit is contained in:
Boof2015
2026-06-17 16:12:51 -04:00
parent 0afbb37c08
commit 9f2ce3bb75
533 changed files with 5933 additions and 147 deletions
@@ -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.
+5
View File
@@ -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})
+35
View File
@@ -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
}
+433
View File
@@ -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
+91
View File
@@ -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
+331
View File
@@ -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
+85
View File
@@ -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
+60
View File
@@ -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"
+124
View File
@@ -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
+132
View File
@@ -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
+45
View File
@@ -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
+110
View File
@@ -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
+66
View File
@@ -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"]
}
}
+21
View File
@@ -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');
+93
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+83 -38
View File
@@ -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;
+114
View File
@@ -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;
+86
View File
@@ -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;
+194
View File
@@ -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
View File
@@ -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> {
+45
View File
@@ -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);
}
+18
View File
@@ -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);
+48
View File
@@ -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);
};
}, []);
}
+91
View File
@@ -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;
}
+80
View File
@@ -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
View File
@@ -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%)
+201
View File
@@ -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
View File
@@ -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')
}
@@ -0,0 +1 @@
o/bundleLibRuntimeToDirDebug

Some files were not shown because too many files have changed in this diff Show More