mirror of
https://github.com/Michatec/Radio.git
synced 2026-05-31 03:12:40 +02:00
feat(ui): add spectrum analyzer visualizer
This commit is contained in:
@@ -26,13 +26,17 @@ project("radio")
|
|||||||
# for GameActivity/NativeActivity derived applications, the same library name must be
|
# for GameActivity/NativeActivity derived applications, the same library name must be
|
||||||
# used in the AndroidManifest.xml file.
|
# used in the AndroidManifest.xml file.
|
||||||
add_library(dsp SHARED
|
add_library(dsp SHARED
|
||||||
# List C/C++ source files with relative paths to this CMakeLists.txt.
|
|
||||||
dsp.cpp)
|
dsp.cpp)
|
||||||
|
|
||||||
|
add_library(extra SHARED
|
||||||
|
extra.cpp)
|
||||||
|
|
||||||
# Specifies libraries CMake should link to your target library. You
|
# Specifies libraries CMake should link to your target library. You
|
||||||
# can link libraries from various origins, such as libraries defined in this
|
# can link libraries from various origins, such as libraries defined in this
|
||||||
# build script, prebuilt third-party libraries, or Android system libraries.
|
# build script, prebuilt third-party libraries, or Android system libraries.
|
||||||
target_link_libraries(dsp
|
target_link_libraries(dsp
|
||||||
# List libraries link to the target library
|
android
|
||||||
|
log)
|
||||||
|
target_link_libraries(extra
|
||||||
android
|
android
|
||||||
log)
|
log)
|
||||||
+27
-281
@@ -12,40 +12,22 @@
|
|||||||
#define M_PI 3.14159265358979323846
|
#define M_PI 3.14159265358979323846
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// OPTIMIZED CONFIGURATION
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// Use L1/L2 cache-optimized block size (typical L1: 32KB, L2: 256KB)
|
|
||||||
static constexpr int FFT_SIZE = 512;
|
static constexpr int FFT_SIZE = 512;
|
||||||
static constexpr int NUM_EQ_BANDS = 10;
|
static constexpr int NUM_EQ_BANDS = 10;
|
||||||
|
|
||||||
// Pre-compute constants at compile time
|
|
||||||
static constexpr float INV_32768 = 1.0f / 32768.0f;
|
static constexpr float INV_32768 = 1.0f / 32768.0f;
|
||||||
static constexpr float SQRT_2_INV = 0.70710678f; // 1/sqrt(2)
|
static constexpr float SQRT_2_INV = 0.70710678f;
|
||||||
|
|
||||||
// Denormal protection - use single scalar instead of adding per-sample
|
|
||||||
static constexpr float DENORMAL_OFFSET = 1e-18f;
|
static constexpr float DENORMAL_OFFSET = 1e-18f;
|
||||||
|
|
||||||
// EQ frequencies - static const for compile-time access
|
|
||||||
static constexpr std::array<float, NUM_EQ_BANDS> EQ_FREQUENCIES = {
|
static constexpr std::array<float, NUM_EQ_BANDS> EQ_FREQUENCIES = {
|
||||||
31.25f, 62.5f, 125.0f, 250.0f, 500.0f,
|
31.25f, 62.5f, 125.0f, 250.0f, 500.0f,
|
||||||
1000.0f, 2000.0f, 4000.0f, 8000.0f, 16000.0f
|
1000.0f, 2000.0f, 4000.0f, 8000.0f, 16000.0f
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// OPTIMIZED DSP CLASSES - Structure of Arrays (SoA) for cache efficiency
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
struct alignas(16) BiquadBank {
|
struct alignas(16) BiquadBank {
|
||||||
// Coefficients (SoA - better for SIMD loads)
|
|
||||||
alignas(16) std::array<float, NUM_EQ_BANDS> a0{}, a1{}, a2{}, b1{}, b2{};
|
alignas(16) std::array<float, NUM_EQ_BANDS> a0{}, a1{}, a2{}, b1{}, b2{};
|
||||||
// State variables
|
|
||||||
alignas(16) std::array<float, NUM_EQ_BANDS> z1{}, z2{};
|
alignas(16) std::array<float, NUM_EQ_BANDS> z1{}, z2{};
|
||||||
// Active flags (packed into bitmask for branch-free processing)
|
|
||||||
uint16_t activeMask = 0;
|
uint16_t activeMask = 0;
|
||||||
|
|
||||||
// Pre-check if any EQ band is active - branch free
|
|
||||||
[[nodiscard]] inline bool hasActiveBands() const { return activeMask != 0; }
|
[[nodiscard]] inline bool hasActiveBands() const { return activeMask != 0; }
|
||||||
|
|
||||||
inline void setBandActive(int band, bool active) {
|
inline void setBandActive(int band, bool active) {
|
||||||
@@ -53,13 +35,10 @@ struct alignas(16) BiquadBank {
|
|||||||
else activeMask &= ~(1 << band);
|
else activeMask &= ~(1 << band);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimized bulk processing for a single channel
|
|
||||||
inline void processBlock(float* __restrict__ data, int count) {
|
inline void processBlock(float* __restrict__ data, int count) {
|
||||||
if (!this -> hasActiveBands()) return;
|
if (!this -> hasActiveBands()) return;
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
float x = data[i];
|
float x = data[i];
|
||||||
// Process all bands (compiler will optimize for activeMask)
|
|
||||||
#pragma GCC unroll 10
|
#pragma GCC unroll 10
|
||||||
for (int b = 0; b < NUM_EQ_BANDS; b++) {
|
for (int b = 0; b < NUM_EQ_BANDS; b++) {
|
||||||
if (activeMask & (1 << b)) {
|
if (activeMask & (1 << b)) {
|
||||||
@@ -75,19 +54,15 @@ struct alignas(16) BiquadBank {
|
|||||||
|
|
||||||
void setPeakingEQ(int band, float sr, float f, float g, float bw) {
|
void setPeakingEQ(int band, float sr, float f, float g, float bw) {
|
||||||
if (band < 0 || band >= NUM_EQ_BANDS) return;
|
if (band < 0 || band >= NUM_EQ_BANDS) return;
|
||||||
|
|
||||||
const bool active = std::abs(g) > 0.1f;
|
const bool active = std::abs(g) > 0.1f;
|
||||||
setBandActive(band, active);
|
setBandActive(band, active);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
const float A = powf(10.0f, g / 40.0f);
|
const float A = powf(10.0f, g / 40.0f);
|
||||||
const float w = 2.0f * static_cast<float>(M_PI) * f / sr;
|
const float w = 2.0f * static_cast<float>(M_PI) * f / sr;
|
||||||
const float alpha = sinf(w) * sinhf(logf(2.0f) / 2.0f * bw * w / sinf(w));
|
const float alpha = sinf(w) * sinhf(logf(2.0f) / 2.0f * bw * w / sinf(w));
|
||||||
const float c = cosf(w);
|
const float c = cosf(w);
|
||||||
|
|
||||||
const float a0_raw = 1.0f + alpha / A;
|
const float a0_raw = 1.0f + alpha / A;
|
||||||
const float invA0 = 1.0f / a0_raw;
|
const float invA0 = 1.0f / a0_raw;
|
||||||
|
|
||||||
a0[band] = (1.0f + alpha * A) * invA0;
|
a0[band] = (1.0f + alpha * A) * invA0;
|
||||||
a1[band] = (-2.0f * c) * invA0;
|
a1[band] = (-2.0f * c) * invA0;
|
||||||
a2[band] = (1.0f - alpha * A) * invA0;
|
a2[band] = (1.0f - alpha * A) * invA0;
|
||||||
@@ -96,61 +71,23 @@ struct alignas(16) BiquadBank {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// BASS BOOST
|
|
||||||
// =============================================================================
|
|
||||||
struct alignas(16) BassFilter {
|
struct alignas(16) BassFilter {
|
||||||
alignas(16) float a0 = 1.2f, a1 = 1.2f, a2 = 1.2f, b1 = 0.0f, b2 = 0.0f;
|
alignas(16) float a0 = 1.2f, a1 = 1.2f, a2 = 1.2f, b1 = 0.0f, b2 = 0.0f;
|
||||||
alignas(16) float z1 = 0.0f, z2 = 0.0f;
|
alignas(16) float z1 = 0.0f, z2 = 0.0f;
|
||||||
bool active = false;
|
bool active = false;
|
||||||
BiquadBank myBank;
|
|
||||||
|
|
||||||
inline float process(float x) {
|
inline float process(float x) {
|
||||||
if (!active) return x;
|
if (!active) return x;
|
||||||
float y = x * a0 + z1;
|
float y = x * a0 + z1;
|
||||||
z1 = x * a1 + z2 - b1 * y + DENORMAL_OFFSET;
|
z1 = x * a1 + z2 - b1 * y + DENORMAL_OFFSET;
|
||||||
z2 = x * a2 - b2 * y;
|
z2 = x * a2 - b2 * y;
|
||||||
y = bassSafeClip(y);
|
if(y > 1.2f) y = 1.2f; else if(y < -1.2f) y = -1.2f;
|
||||||
if(y > 1.2f) y = 1.2f;
|
|
||||||
else if(y < -1.2f) y = -1.2f;
|
|
||||||
return y;
|
return y;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline void processNEON(float* __restrict__ data, int count) {
|
|
||||||
#if defined(__ARM_NEON)
|
|
||||||
if (!active) return;
|
|
||||||
int i = 0;
|
|
||||||
for (; i <= count-4; i+=4) {
|
|
||||||
float32x4_t x = vld1q_f32(data + i);
|
|
||||||
for(int b=0;b<NUM_EQ_BANDS;b++){
|
|
||||||
if(active){
|
|
||||||
float32x4_t y = vmlaq_n_f32(vdupq_n_f32(z1), x, a0);
|
|
||||||
float32x4_t z1n = vmlaq_n_f32(vdupq_n_f32(z2), x, a1) - vmulq_n_f32(y, b1) + vdupq_n_f32(DENORMAL_OFFSET);
|
|
||||||
float32x4_t z2n = vmlaq_n_f32(vdupq_n_f32(0.0f), x, a2) - vmulq_n_f32(y, b2);
|
|
||||||
z1 = vgetq_lane_f32(z1n,3);
|
|
||||||
z2 = vgetq_lane_f32(z2n,3);
|
|
||||||
x = y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vst1q_f32(data+i, x);
|
|
||||||
}
|
|
||||||
for(;i<count;i++) myBank.processBlock(data+i,1); // Rest scalar
|
|
||||||
#else
|
|
||||||
for(int i=0;i<count;i++) data[i]=process(data[i]);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline float bassSafeClip(float x) {
|
|
||||||
const float maxGain = 1.0f;
|
|
||||||
if (x > maxGain) return maxGain - (maxGain - x) * 0.5f;
|
|
||||||
if (x < -maxGain) return -maxGain - (-maxGain - x) * 0.5f;
|
|
||||||
return x;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setLowShelf(float sr,float f,float g,float q){
|
void setLowShelf(float sr,float f,float g,float q){
|
||||||
active=std::abs(g)>0.01f;
|
active=std::abs(g)>0.01f;
|
||||||
if(!active) return;
|
if(!active) return;
|
||||||
|
|
||||||
float A=powf(10.0f,g/40.0f);
|
float A=powf(10.0f,g/40.0f);
|
||||||
float w=2.0f*static_cast<float>(M_PI)*f/sr;
|
float w=2.0f*static_cast<float>(M_PI)*f/sr;
|
||||||
float alpha=sinf(w)/2.0f*sqrtf((A+1.0f/A)*(1.0f/q-1.0f)+2.0f);
|
float alpha=sinf(w)/2.0f*sqrtf((A+1.0f/A)*(1.0f/q-1.0f)+2.0f);
|
||||||
@@ -165,40 +102,24 @@ struct alignas(16) BassFilter {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// LOCK-FREE REVERB - Fixed-size circular buffers (no heap allocation)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
template<int SIZE>
|
template<int SIZE>
|
||||||
struct CircularBuffer {
|
struct CircularBuffer {
|
||||||
alignas(16) std::array<float, SIZE> data = {};
|
alignas(16) std::array<float, SIZE> data = {};
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
|
|
||||||
[[nodiscard]] inline float read() const { return data[pos]; }
|
[[nodiscard]] inline float read() const { return data[pos]; }
|
||||||
inline void write(float v) { data[pos] = v; }
|
inline void write(float v) { data[pos] = v; }
|
||||||
inline void advance() { pos = (pos + 1) % SIZE; }
|
inline void advance() { pos = (pos + 1) % SIZE; }
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class ReverbOptimized {
|
class ReverbOptimized {
|
||||||
// Classic Schroeder: 4 parallel comb filters + 2 series allpass
|
|
||||||
// Fixed buffer sizes for lock-free operation
|
|
||||||
std::array<CircularBuffer<1116>, 4> combs;
|
std::array<CircularBuffer<1116>, 4> combs;
|
||||||
std::array<CircularBuffer<556>, 2> allpasses;
|
std::array<CircularBuffer<556>, 2> allpasses;
|
||||||
std::array<float, 4> combFeedback = {0.841f, 0.815f, 0.796f, 0.771f};
|
std::array<float, 4> combFeedback = {0.841f, 0.815f, 0.796f, 0.771f};
|
||||||
|
|
||||||
float mix = 0.0f;
|
float mix = 0.0f;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
ReverbOptimized() = default;
|
|
||||||
|
|
||||||
inline void setMix(float m) { mix = m; }
|
inline void setMix(float m) { mix = m; }
|
||||||
|
|
||||||
// Branch-free processing with inline inlining
|
|
||||||
inline float process(float x) {
|
inline float process(float x) {
|
||||||
if (mix < 0.01f) return x;
|
if (mix < 0.01f) return x;
|
||||||
|
|
||||||
// Parallel comb filters (unrolled for ARM NEON)
|
|
||||||
float out = 0.0f;
|
float out = 0.0f;
|
||||||
#pragma GCC unroll 4
|
#pragma GCC unroll 4
|
||||||
for (int i = 0; i < 4; i++) {
|
for (int i = 0; i < 4; i++) {
|
||||||
@@ -207,9 +128,7 @@ public:
|
|||||||
combs[i].write(x + delayed * combFeedback[i] + DENORMAL_OFFSET);
|
combs[i].write(x + delayed * combFeedback[i] + DENORMAL_OFFSET);
|
||||||
combs[i].advance();
|
combs[i].advance();
|
||||||
}
|
}
|
||||||
out *= 0.25f; // 1/4 normalization
|
out *= 0.25f;
|
||||||
|
|
||||||
// Series allpass filters
|
|
||||||
for (int i = 0; i < 2; i++) {
|
for (int i = 0; i < 2; i++) {
|
||||||
float bufOut = allpasses[i].read();
|
float bufOut = allpasses[i].read();
|
||||||
float xOut = -0.5f * out + bufOut;
|
float xOut = -0.5f * out + bufOut;
|
||||||
@@ -217,14 +136,10 @@ public:
|
|||||||
allpasses[i].advance();
|
allpasses[i].advance();
|
||||||
out = xOut;
|
out = xOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
return x * (1.0f - mix) + out * mix;
|
return x * (1.0f - mix) + out * mix;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEON-optimized block processing
|
|
||||||
inline void processBlock(float* __restrict__ left, float* __restrict__ right, int count) {
|
inline void processBlock(float* __restrict__ left, float* __restrict__ right, int count) {
|
||||||
if (mix < 0.01f) return;
|
if (mix < 0.01f) return;
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
left[i] = process(left[i]);
|
left[i] = process(left[i]);
|
||||||
right[i] = process(right[i]);
|
right[i] = process(right[i]);
|
||||||
@@ -232,28 +147,13 @@ public:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// OPTIMIZED COMPRESSOR - Per-channel state, branch-free envelope
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
class CompressorOptimized {
|
class CompressorOptimized {
|
||||||
public:
|
public:
|
||||||
float threshold = 0.3f;
|
float threshold = 0.3f, ratio = 4.0f, attack = 0.08f, release = 0.8f, sampleRate = 44100.0f;
|
||||||
float ratio = 4.0f;
|
|
||||||
float attack = 0.08f;
|
|
||||||
float release = 0.8f;
|
|
||||||
float sampleRate = 44100.0f;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Per-channel envelope state
|
float envelopeL = 0.0f, envelopeR = 0.0f;
|
||||||
float envelopeL = 0.0f;
|
float attackCoef = 0.0f, releaseCoef = 0.0f;
|
||||||
float envelopeR = 0.0f;
|
|
||||||
|
|
||||||
// Pre-computed coefficients
|
|
||||||
float attackCoef = 0.0f;
|
|
||||||
float releaseCoef = 0.0f;
|
|
||||||
bool coefficientsValid = false;
|
bool coefficientsValid = false;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
inline void updateCoefficients() {
|
inline void updateCoefficients() {
|
||||||
if (coefficientsValid) return;
|
if (coefficientsValid) return;
|
||||||
@@ -261,70 +161,41 @@ public:
|
|||||||
releaseCoef = expf(-1.0f / (release * sampleRate));
|
releaseCoef = expf(-1.0f / (release * sampleRate));
|
||||||
coefficientsValid = true;
|
coefficientsValid = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline void processBlock(float* __restrict__ buffer, int count, float& envelope) {
|
inline void processBlock(float* __restrict__ buffer, int count, float& envelope) {
|
||||||
updateCoefficients();
|
updateCoefficients();
|
||||||
const int blockSize = 32;
|
for(int i=0; i<count; i++){
|
||||||
for(int b=0;b<count;b+=blockSize){
|
float absInput = fabsf(buffer[i]);
|
||||||
int sz = (b+blockSize<count)? blockSize : count-b;
|
envelope = (absInput > envelope) ? attackCoef*envelope + (1-attackCoef)*absInput : releaseCoef*envelope + (1-releaseCoef)*absInput;
|
||||||
float maxVal = 0.0f;
|
|
||||||
for(int i=0;i<sz;i++){
|
|
||||||
float absInput = fabsf(buffer[b+i]);
|
|
||||||
if(absInput>maxVal) maxVal = absInput;
|
|
||||||
}
|
|
||||||
bool attackMode = maxVal > envelope;
|
|
||||||
envelope = attackMode ? attackCoef*envelope + (1-attackCoef)*maxVal
|
|
||||||
: releaseCoef*envelope + (1-releaseCoef)*maxVal;
|
|
||||||
float gain = (envelope>threshold)? (threshold + (envelope-threshold)/ratio)/(envelope+1e-9f) : 1.0f;
|
float gain = (envelope>threshold)? (threshold + (envelope-threshold)/ratio)/(envelope+1e-9f) : 1.0f;
|
||||||
for(int i=0;i<sz;i++) buffer[b+i]*=gain;
|
buffer[i]*=gain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inline void process(float* __restrict__ left, float* __restrict__ right, int count) {
|
inline void process(float* __restrict__ left, float* __restrict__ right, int count) {
|
||||||
processBlock(left, count, envelopeL);
|
processBlock(left, count, envelopeL);
|
||||||
processBlock(right, count, envelopeR);
|
processBlock(right, count, envelopeR);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// GLOBAL ENGINE - SoA layout for cache efficiency
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
CompressorOptimized gCompressor;
|
CompressorOptimized gCompressor;
|
||||||
ReverbOptimized gReverbL, gReverbR;
|
ReverbOptimized gReverbL, gReverbR;
|
||||||
BiquadBank gEqL, gEqR;
|
BiquadBank gEqL, gEqR;
|
||||||
BassFilter gBassL, gBassR;
|
BassFilter gBassL, gBassR;
|
||||||
|
bool gDrcEnabled = false, gEqEnabled = false, gBassBoostEnabled = false;
|
||||||
// Global state flags
|
|
||||||
bool gDrcEnabled = false;
|
|
||||||
bool gEqEnabled = false; // Derived from gEqL.hasActiveBands()
|
|
||||||
bool gBassBoostEnabled = false;
|
|
||||||
float gStereoWidth = 1.0f;
|
float gStereoWidth = 1.0f;
|
||||||
float gTargetRMS = 0.20f;
|
alignas(16) std::array<float, 4096> gLeftBuf, gRightBuf;
|
||||||
float gCurrentGain = 1.0f;
|
|
||||||
|
|
||||||
// Pre-allocated buffers - fixed size to avoid heap allocation in real-time
|
|
||||||
alignas(16) std::array<float, 4096> gLeftBuf;
|
|
||||||
alignas(16) std::array<float, 4096> gRightBuf;
|
|
||||||
alignas(16) std::array<float, 256> gFFTData;
|
alignas(16) std::array<float, 256> gFFTData;
|
||||||
alignas(16) std::array<std::complex<float>, FFT_SIZE> gFFTWork;
|
alignas(16) std::array<std::complex<float>, FFT_SIZE> gFFTWork;
|
||||||
|
|
||||||
// Fast FFT - iterative Cooley-Tukey
|
|
||||||
inline void fastFFT(std::complex<float>* __restrict__ data, int n) {
|
inline void fastFFT(std::complex<float>* __restrict__ data, int n) {
|
||||||
// Bit-reversal permutation (iterative, cache-friendly)
|
|
||||||
for (int i = 1, j = 0; i < n; i++) {
|
for (int i = 1, j = 0; i < n; i++) {
|
||||||
int bit = n >> 1;
|
int bit = n >> 1;
|
||||||
for (; j & bit; bit >>= 1) j ^= bit;
|
for (; j & bit; bit >>= 1) j ^= bit;
|
||||||
j ^= bit;
|
j ^= bit;
|
||||||
if (i < j) std::swap(data[i], data[j]);
|
if (i < j) std::swap(data[i], data[j]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooley-Tukey stages
|
|
||||||
for (int len = 2; len <= n; len <<= 1) {
|
for (int len = 2; len <= n; len <<= 1) {
|
||||||
float ang = -2.0f * static_cast<float>(M_PI) / static_cast<float>(len);
|
float ang = -2.0f * static_cast<float>(M_PI) / static_cast<float>(len);
|
||||||
// Pre-compute wlen - critical for performance
|
|
||||||
std::complex<float> wlen(cosf(ang), sinf(ang));
|
std::complex<float> wlen(cosf(ang), sinf(ang));
|
||||||
|
|
||||||
for (int i = 0; i < n; i += len) {
|
for (int i = 0; i < n; i += len) {
|
||||||
std::complex<float> w(1.0f);
|
std::complex<float> w(1.0f);
|
||||||
for (int j = 0; j < len / 2; j++) {
|
for (int j = 0; j < len / 2; j++) {
|
||||||
@@ -338,83 +209,17 @@ inline void fastFFT(std::complex<float>* __restrict__ data, int n) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// HIGH-PERFORMANCE AUDIO PROCESSING
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// Fast soft clipping with polynomial approximation
|
|
||||||
inline float fastSoftClip(float x) {
|
inline float fastSoftClip(float x) {
|
||||||
// Branchless clipping using min/max
|
|
||||||
float ax = fabsf(x);
|
float ax = fabsf(x);
|
||||||
float sign = x > 0 ? 1.0f : -1.0f;
|
float sign = x > 0 ? 1.0f : -1.0f;
|
||||||
if (ax > 1.0f) return sign;
|
if (ax > 1.0f) return sign;
|
||||||
return x * (1.4f - 0.4f * x * x);
|
return x * (1.5f - 0.5f * x * x);
|
||||||
}
|
}
|
||||||
|
|
||||||
inline void applyAutoGain(float* buffer, int count){
|
|
||||||
int block = 128;
|
|
||||||
for(int i=0; i<count; i+=block){
|
|
||||||
int sz = (i+block<count) ? block : count-i;
|
|
||||||
float sumSq = 0.0f;
|
|
||||||
|
|
||||||
#if defined(__ARM_NEON)
|
|
||||||
float32x4_t sumVec = vdupq_n_f32(0.0f);
|
|
||||||
int j=0;
|
|
||||||
for(; j<=sz-4; j+=4){
|
|
||||||
float32x4_t v = vld1q_f32(buffer + i + j);
|
|
||||||
sumVec = vmlaq_f32(sumVec, v, v);
|
|
||||||
}
|
|
||||||
float32x2_t lo = vget_low_f32(sumVec);
|
|
||||||
float32x2_t hi = vget_high_f32(sumVec);
|
|
||||||
sumSq = vget_lane_f32(lo,0) + vget_lane_f32(lo,1) + vget_lane_f32(hi,0) + vget_lane_f32(hi,1);
|
|
||||||
for(; j<sz; j++) sumSq += buffer[i+j]*buffer[i+j];
|
|
||||||
#else
|
|
||||||
for(int j=0; j<sz; j++) sumSq += buffer[i+j]*buffer[i+j];
|
|
||||||
#endif
|
|
||||||
|
|
||||||
float rms = sqrtf(sumSq / static_cast<float>(sz));
|
|
||||||
if(rms > 0.001f){
|
|
||||||
float target = gTargetRMS / rms;
|
|
||||||
gCurrentGain = gCurrentGain*0.99f + target*0.01f;
|
|
||||||
if(gCurrentGain > 2.0f) gCurrentGain = 2.0f;
|
|
||||||
|
|
||||||
#if defined(__ARM_NEON)
|
|
||||||
float32x4_t gVec = vdupq_n_f32(gCurrentGain);
|
|
||||||
int j=0;
|
|
||||||
for(; j<=sz-4; j+=4){
|
|
||||||
float32x4_t v = vld1q_f32(buffer + i + j);
|
|
||||||
vst1q_f32(buffer + i + j, vmulq_f32(v, gVec));
|
|
||||||
}
|
|
||||||
for(; j<sz; j++) buffer[i+j] *= gCurrentGain;
|
|
||||||
#else
|
|
||||||
for(int j=0; j<sz; j++) buffer[i+j] *= gCurrentGain;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inline void applyRMSLimit(float* buffer, int count){
|
|
||||||
float sumSq = 0.0f;
|
|
||||||
for(int i=0;i<count;i++) sumSq += buffer[i]*buffer[i];
|
|
||||||
float rms = sqrtf(sumSq / float(count));
|
|
||||||
if(rms > 0.8f){
|
|
||||||
float scale = 0.8f / rms;
|
|
||||||
for(int i=0;i<count;i++) buffer[i] *= scale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main processing function - heavily optimized
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
|
|
||||||
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setDrcEnabled(JNIEnv*, jobject, jboolean e) {
|
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setDrcEnabled(JNIEnv*, jobject, jboolean e) { gDrcEnabled = e; }
|
||||||
gDrcEnabled = e;
|
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setReverbMix(JNIEnv*, jobject, jfloat m) { gReverbL.setMix(m); gReverbR.setMix(m); }
|
||||||
}
|
|
||||||
|
|
||||||
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setReverbMix(JNIEnv*, jobject, jfloat m) {
|
|
||||||
gReverbL.setMix(m);
|
|
||||||
gReverbR.setMix(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setEqBand(JNIEnv*, jobject, jint b, jfloat g) {
|
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setEqBand(JNIEnv*, jobject, jint b, jfloat g) {
|
||||||
if (b >= 0 && b < NUM_EQ_BANDS) {
|
if (b >= 0 && b < NUM_EQ_BANDS) {
|
||||||
gEqL.setPeakingEQ(b, 44100.0f, EQ_FREQUENCIES[b], g, 1.0f);
|
gEqL.setPeakingEQ(b, 44100.0f, EQ_FREQUENCIES[b], g, 1.0f);
|
||||||
@@ -422,20 +227,14 @@ JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setE
|
|||||||
}
|
}
|
||||||
gEqEnabled = gEqL.hasActiveBands();
|
gEqEnabled = gEqL.hasActiveBands();
|
||||||
}
|
}
|
||||||
|
|
||||||
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setBassBoost(JNIEnv*, jobject, jfloat g) {
|
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setBassBoost(JNIEnv*, jobject, jfloat g) {
|
||||||
if (g > 0.01f) {
|
if (g > 0.01f) {
|
||||||
gBassL.setLowShelf(44100.0f, 150.0f, g, SQRT_2_INV);
|
gBassL.setLowShelf(44100.0f, 150.0f, g, SQRT_2_INV);
|
||||||
gBassR.setLowShelf(44100.0f, 150.0f, g, SQRT_2_INV);
|
gBassR.setLowShelf(44100.0f, 150.0f, g, SQRT_2_INV);
|
||||||
gBassBoostEnabled = true;
|
gBassBoostEnabled = true;
|
||||||
} else {
|
} else { gBassBoostEnabled = false; }
|
||||||
gBassBoostEnabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setStereoWidth(JNIEnv*, jobject, jfloat w) {
|
|
||||||
gStereoWidth = fmaxf(0.0f, fminf(w, 2.0f));
|
|
||||||
}
|
}
|
||||||
|
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setStereoWidth(JNIEnv*, jobject, jfloat w) { gStereoWidth = fmaxf(0.0f, fminf(w, 2.0f)); }
|
||||||
|
|
||||||
JNIEXPORT jfloatArray JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_getFftData(JNIEnv* env, jobject) {
|
JNIEXPORT jfloatArray JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_getFftData(JNIEnv* env, jobject) {
|
||||||
jfloatArray arr = env->NewFloatArray(256);
|
jfloatArray arr = env->NewFloatArray(256);
|
||||||
@@ -446,96 +245,43 @@ JNIEXPORT jfloatArray JNICALL Java_com_michatec_radio_helpers_NativeAudioProcess
|
|||||||
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_processAudioDirect(JNIEnv* env, jobject, jobject byteBuffer, jint size) {
|
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_processAudioDirect(JNIEnv* env, jobject, jobject byteBuffer, jint size) {
|
||||||
auto* buffer = static_cast<jshort*>(env->GetDirectBufferAddress(byteBuffer));
|
auto* buffer = static_cast<jshort*>(env->GetDirectBufferAddress(byteBuffer));
|
||||||
if (!buffer) return;
|
if (!buffer) return;
|
||||||
|
|
||||||
int numFrames = (size / 2) / 2;
|
int numFrames = (size / 2) / 2;
|
||||||
if (numFrames > 4096) numFrames = 4096; // Clamp to buffer size
|
if (numFrames > 4096) numFrames = 4096;
|
||||||
|
|
||||||
// =========================================================================
|
for (int i = 0; i < numFrames; i++) {
|
||||||
// STAGE 1: Convert to Float (NEON optimized, interleaved stereo)
|
|
||||||
// =========================================================================
|
|
||||||
int i = 0;
|
|
||||||
#if defined(__ARM_NEON)
|
|
||||||
float32x4_t invScale = vdupq_n_f32(INV_32768);
|
|
||||||
for (; i <= numFrames - 4; i += 4) {
|
|
||||||
// Load interleaved 16-bit stereo, deinterleave to two floats
|
|
||||||
int16x4x2_t raw = vld2_s16(buffer + i * 2);
|
|
||||||
// Expand to 32-bit, convert to float, scale
|
|
||||||
float32x4_t left = vmulq_f32(vcvtq_f32_s32(vmovl_s16(raw.val[0])), invScale);
|
|
||||||
float32x4_t right = vmulq_f32(vcvtq_f32_s32(vmovl_s16(raw.val[1])), invScale);
|
|
||||||
vst1q_f32(gLeftBuf.data() + i, left);
|
|
||||||
vst1q_f32(gRightBuf.data() + i, right);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
// Scalar tail
|
|
||||||
for (; i < numFrames; i++) {
|
|
||||||
gLeftBuf[i] = static_cast<float>(buffer[i * 2]) * INV_32768;
|
gLeftBuf[i] = static_cast<float>(buffer[i * 2]) * INV_32768;
|
||||||
gRightBuf[i] = static_cast<float>(buffer[i * 2 + 1]) * INV_32768;
|
gRightBuf[i] = static_cast<float>(buffer[i * 2 + 1]) * INV_32768;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
if (gEqEnabled) { gEqL.processBlock(gLeftBuf.data(), numFrames); gEqR.processBlock(gRightBuf.data(), numFrames); }
|
||||||
// STAGE 2: DSP Chain (EQ -> Bass -> Reverb -> Stereo Width)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
// EQ processing (branch-free based on active mask)
|
|
||||||
if (gEqEnabled) {
|
|
||||||
gEqL.processBlock(gLeftBuf.data(), numFrames);
|
|
||||||
gEqR.processBlock(gRightBuf.data(), numFrames);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bass boost
|
|
||||||
if (gBassBoostEnabled) {
|
if (gBassBoostEnabled) {
|
||||||
gBassL.processNEON(gLeftBuf.data(), numFrames);
|
for(int i=0; i<numFrames; i++) { gLeftBuf[i] = gBassL.process(gLeftBuf[i]); gRightBuf[i] = gBassR.process(gRightBuf[i]); }
|
||||||
gBassR.processNEON(gRightBuf.data(), numFrames);
|
|
||||||
|
|
||||||
applyRMSLimit(gLeftBuf.data(), numFrames);
|
|
||||||
applyRMSLimit(gRightBuf.data(), numFrames);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverb
|
|
||||||
gReverbL.processBlock(gLeftBuf.data(), gRightBuf.data(), numFrames);
|
gReverbL.processBlock(gLeftBuf.data(), gRightBuf.data(), numFrames);
|
||||||
|
|
||||||
// Stereo width processing (branch-free)
|
|
||||||
if (gStereoWidth != 1.0f) {
|
if (gStereoWidth != 1.0f) {
|
||||||
float halfWidth = gStereoWidth * 0.5f;
|
float halfWidth = gStereoWidth * 0.5f;
|
||||||
for (int j = 0; j < numFrames; j++) {
|
for (int j = 0; j < numFrames; j++) {
|
||||||
float mid = (gLeftBuf[j] + gRightBuf[j]) * 0.5f;
|
float mid = (gLeftBuf[j] + gRightBuf[j]) * 0.5f;
|
||||||
float side = (gLeftBuf[j] - gRightBuf[j]) * halfWidth;
|
float side = (gLeftBuf[j] - gRightBuf[j]) * halfWidth;
|
||||||
gLeftBuf[j] = mid + side;
|
gLeftBuf[j] = mid + side; gRightBuf[j] = mid - side;
|
||||||
gRightBuf[j] = mid - side;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
if (gDrcEnabled) gCompressor.process(gLeftBuf.data(), gRightBuf.data(), numFrames);
|
||||||
// STAGE 3: Dynamic Control (AutoGain -> Compressor)
|
|
||||||
// =========================================================================
|
|
||||||
applyAutoGain(gLeftBuf.data(), numFrames);
|
|
||||||
applyAutoGain(gRightBuf.data(), numFrames);
|
|
||||||
|
|
||||||
if (gDrcEnabled) {
|
// FFT for visualization
|
||||||
gCompressor.process(gLeftBuf.data(), gRightBuf.data(), numFrames);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// STAGE 4: FFT Analysis (downsampled for visualization)
|
|
||||||
// =========================================================================
|
|
||||||
// Zero-pad for FFT (use first 256 samples only)
|
|
||||||
for (int k = 0; k < FFT_SIZE; k++) {
|
for (int k = 0; k < FFT_SIZE; k++) {
|
||||||
gFFTWork[k] = (k < 256) ? std::complex<float>(gLeftBuf[k], 0.0f) : std::complex<float>(0.0f, 0.0f);
|
gFFTWork[k] = (k < 256 && k < numFrames) ? std::complex<float>(gLeftBuf[k], 0.0f) : std::complex<float>(0.0f, 0.0f);
|
||||||
}
|
}
|
||||||
fastFFT(gFFTWork.data(), FFT_SIZE);
|
fastFFT(gFFTWork.data(), FFT_SIZE);
|
||||||
|
|
||||||
// Compute magnitude spectrum (only first 256 bins)
|
|
||||||
for (int k = 0; k < 256; k++) {
|
for (int k = 0; k < 256; k++) {
|
||||||
gFFTData[k] = std::abs(gFFTWork[k]) * 0.05f;
|
gFFTData[k] = std::abs(gFFTWork[k]) * 0.5f; // Increased scale
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// STAGE 5: Convert back to 16-bit with soft clipping
|
|
||||||
// =========================================================================
|
|
||||||
for (int k = 0; k < numFrames; k++) {
|
for (int k = 0; k < numFrames; k++) {
|
||||||
buffer[k * 2] = static_cast<jshort>(fastSoftClip(gLeftBuf[k]) * 32767.0f);
|
buffer[k * 2] = static_cast<jshort>(fastSoftClip(gLeftBuf[k]) * 32767.0f);
|
||||||
buffer[k * 2 + 1] = static_cast<jshort>(fastSoftClip(gRightBuf[k]) * 32767.0f);
|
buffer[k * 2 + 1] = static_cast<jshort>(fastSoftClip(gRightBuf[k]) * 32767.0f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} // extern "C"
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#include <jni.h>
|
||||||
|
#include <android/native_window_jni.h>
|
||||||
|
#include <android/native_window.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <vector>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_michatec_radio_helpers_ExtrasHelper_visualize(JNIEnv *env, jclass clazz, jobject surface, jfloatArray data) {
|
||||||
|
if (!surface) return;
|
||||||
|
ANativeWindow* window = ANativeWindow_fromSurface(env, surface);
|
||||||
|
if (!window) return;
|
||||||
|
|
||||||
|
jsize len = env->GetArrayLength(data);
|
||||||
|
if (len == 0) {
|
||||||
|
ANativeWindow_release(window);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jfloat* body = env->GetFloatArrayElements(data, nullptr);
|
||||||
|
|
||||||
|
ANativeWindow_Buffer buffer;
|
||||||
|
ANativeWindow_setBuffersGeometry(window, 0, 0, WINDOW_FORMAT_RGBA_8888);
|
||||||
|
|
||||||
|
if (ANativeWindow_lock(window, &buffer, nullptr) == 0) {
|
||||||
|
auto* pixels = static_cast<uint32_t*>(buffer.bits);
|
||||||
|
|
||||||
|
// Clear background (Dark Grey)
|
||||||
|
for (int y = 0; y < buffer.height; y++) {
|
||||||
|
for (int x = 0; x < buffer.width; x++) {
|
||||||
|
pixels[y * buffer.stride + x] = 0xFF121212;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw bars
|
||||||
|
int displayBins = std::min(static_cast<int>(len), 128);
|
||||||
|
float barWidth = static_cast<float>(buffer.width) / static_cast<float>(displayBins);
|
||||||
|
|
||||||
|
for (int i = 0; i < displayBins; i++) {
|
||||||
|
// Keep original order: bass (low freq) at left, treble (high freq) at right
|
||||||
|
float val = body[i];
|
||||||
|
float scaledVal = val * 5.0f;
|
||||||
|
int barHeight = static_cast<int>(scaledVal * static_cast<float>(buffer.height));
|
||||||
|
|
||||||
|
if (barHeight > buffer.height) barHeight = buffer.height;
|
||||||
|
if (barHeight < 12) barHeight = 12; // Min height
|
||||||
|
|
||||||
|
int startX = static_cast<int>(static_cast<float>(i) * barWidth);
|
||||||
|
int endX = static_cast<int>(static_cast<float>(i + 1) * barWidth);
|
||||||
|
int barBottom = buffer.height;
|
||||||
|
int barTop = barBottom - barHeight;
|
||||||
|
|
||||||
|
for (int x = startX; x <= endX; x++) {
|
||||||
|
if (x < 0 || x >= buffer.width) continue;
|
||||||
|
for (int y = barTop; y < barBottom; y++) {
|
||||||
|
pixels[y * buffer.stride + x] = 0xFFC5DA03;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ANativeWindow_unlockAndPost(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
env->ReleaseFloatArrayElements(data, body, JNI_ABORT);
|
||||||
|
ANativeWindow_release(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // extern "C"
|
||||||
@@ -27,6 +27,7 @@ object Keys {
|
|||||||
const val EXTRA_START_LAST_PLAYED_STATION: String = "START_LAST_PLAYED_STATION"
|
const val EXTRA_START_LAST_PLAYED_STATION: String = "START_LAST_PLAYED_STATION"
|
||||||
const val EXTRA_SLEEP_TIMER_REMAINING: String = "SLEEP_TIMER_REMAINING"
|
const val EXTRA_SLEEP_TIMER_REMAINING: String = "SLEEP_TIMER_REMAINING"
|
||||||
const val EXTRA_METADATA_HISTORY: String = "METADATA_HISTORY"
|
const val EXTRA_METADATA_HISTORY: String = "METADATA_HISTORY"
|
||||||
|
const val EXTRA_VISUALIZER_DATA: String = "VISUALIZER_DATA"
|
||||||
|
|
||||||
// arguments
|
// arguments
|
||||||
const val ARG_UPDATE_COLLECTION: String = "ArgUpdateCollection"
|
const val ARG_UPDATE_COLLECTION: String = "ArgUpdateCollection"
|
||||||
@@ -43,6 +44,7 @@ object Keys {
|
|||||||
const val CMD_PLAY_STREAM: String = "PLAY_STREAM"
|
const val CMD_PLAY_STREAM: String = "PLAY_STREAM"
|
||||||
const val CMD_REQUEST_SLEEP_TIMER_REMAINING: String = "REQUEST_SLEEP_TIMER_REMAINING"
|
const val CMD_REQUEST_SLEEP_TIMER_REMAINING: String = "REQUEST_SLEEP_TIMER_REMAINING"
|
||||||
const val CMD_REQUEST_METADATA_HISTORY: String = "REQUEST_METADATA_HISTORY"
|
const val CMD_REQUEST_METADATA_HISTORY: String = "REQUEST_METADATA_HISTORY"
|
||||||
|
const val CMD_GET_VISUALIZER_DATA: String = "GET_VISUALIZER_DATA"
|
||||||
|
|
||||||
// preferences
|
// preferences
|
||||||
const val PREF_RADIO_BROWSER_API: String = "RADIO_BROWSER_API"
|
const val PREF_RADIO_BROWSER_API: String = "RADIO_BROWSER_API"
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
|
|||||||
builder.add(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY))
|
builder.add(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY))
|
||||||
builder.add(SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY))
|
builder.add(SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY))
|
||||||
builder.add(SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY))
|
builder.add(SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY))
|
||||||
|
builder.add(SessionCommand(Keys.CMD_GET_VISUALIZER_DATA, Bundle.EMPTY))
|
||||||
return MediaSession.ConnectionResult.accept(builder.build(), connectionResult.availablePlayerCommands)
|
return MediaSession.ConnectionResult.accept(builder.build(), connectionResult.availablePlayerCommands)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +462,19 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Keys.CMD_GET_VISUALIZER_DATA -> {
|
||||||
|
val resultBundle = Bundle()
|
||||||
|
resultBundle.putFloatArray(
|
||||||
|
Keys.EXTRA_VISUALIZER_DATA,
|
||||||
|
nativeAudioProcessor.getVisualizer()
|
||||||
|
)
|
||||||
|
return Futures.immediateFuture(
|
||||||
|
SessionResult(
|
||||||
|
SessionResult.RESULT_SUCCESS,
|
||||||
|
resultBundle
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return super.onCustomCommand(session, controller, customCommand, args)
|
return super.onCustomCommand(session, controller, customCommand, args)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,14 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
|
|||||||
return@setOnPreferenceClickListener true
|
return@setOnPreferenceClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val preferenceVisualizer = Preference(context)
|
||||||
|
preferenceVisualizer.title = getString(R.string.pref_visualizer_title)
|
||||||
|
preferenceVisualizer.setIcon(R.drawable.ic_music_note_24dp)
|
||||||
|
preferenceVisualizer.setOnPreferenceClickListener {
|
||||||
|
findNavController().navigate(R.id.action_settings_to_visualizer)
|
||||||
|
return@setOnPreferenceClickListener true
|
||||||
|
}
|
||||||
|
|
||||||
// set up "App Version" preference
|
// set up "App Version" preference
|
||||||
val preferenceAppVersion = Preference(context)
|
val preferenceAppVersion = Preference(context)
|
||||||
preferenceAppVersion.title = getString(R.string.pref_app_version_title)
|
preferenceAppVersion.title = getString(R.string.pref_app_version_title)
|
||||||
@@ -328,6 +336,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
|
|||||||
preferenceCategoryAudioEffects.addPreference(preferenceDrc)
|
preferenceCategoryAudioEffects.addPreference(preferenceDrc)
|
||||||
preferenceCategoryAudioEffects.addPreference(preferencePresetSelection)
|
preferenceCategoryAudioEffects.addPreference(preferencePresetSelection)
|
||||||
preferenceCategoryAudioEffects.addPreference(preferenceEqualizer)
|
preferenceCategoryAudioEffects.addPreference(preferenceEqualizer)
|
||||||
|
preferenceCategoryAudioEffects.addPreference(preferenceVisualizer)
|
||||||
|
|
||||||
screen.addPreference(preferenceCategoryMaintenance)
|
screen.addPreference(preferenceCategoryMaintenance)
|
||||||
preferenceCategoryMaintenance.addPreference(preferenceUpdateStationImages)
|
preferenceCategoryMaintenance.addPreference(preferenceUpdateStationImages)
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.michatec.radio
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.session.MediaController
|
||||||
|
import androidx.media3.session.SessionToken
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
import com.google.common.util.concurrent.MoreExecutors
|
||||||
|
import com.michatec.radio.extensions.requestVisualizerData
|
||||||
|
import com.michatec.radio.helpers.ExtrasHelper
|
||||||
|
|
||||||
|
/*
|
||||||
|
* VisualizerFragment class: Handles audio visualization
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class VisualizerFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
|
private val TAG = "VisualizerFragment"
|
||||||
|
private lateinit var controllerFuture: ListenableFuture<MediaController>
|
||||||
|
private val controller: MediaController?
|
||||||
|
get() = if (this::controllerFuture.isInitialized && controllerFuture.isDone) {
|
||||||
|
try { controllerFuture.get() } catch (_: Exception) { null }
|
||||||
|
} else null
|
||||||
|
|
||||||
|
private var visualizerPref: ExtrasHelper.VisualizerPreference? = null
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
(activity as AppCompatActivity).supportActionBar?.title = getString(R.string.pref_visualizer_title)
|
||||||
|
(activity as AppCompatActivity).supportActionBar?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
val context = preferenceManager.context
|
||||||
|
val screen = preferenceManager.createPreferenceScreen(context)
|
||||||
|
|
||||||
|
visualizerPref = ExtrasHelper.VisualizerPreference(context)
|
||||||
|
visualizerPref?.key = "visualizer_key"
|
||||||
|
screen.addPreference(visualizerPref!!)
|
||||||
|
|
||||||
|
preferenceScreen = screen
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
initializeController()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
releaseController()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeController() {
|
||||||
|
controllerFuture = MediaController.Builder(
|
||||||
|
requireContext(),
|
||||||
|
SessionToken(requireContext(), ComponentName(requireContext(), PlayerService::class.java))
|
||||||
|
).buildAsync()
|
||||||
|
controllerFuture.addListener({
|
||||||
|
Log.d(TAG, "MediaController connected: ${controller != null}")
|
||||||
|
}, MoreExecutors.directExecutor())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseController() {
|
||||||
|
if (this::controllerFuture.isInitialized) {
|
||||||
|
MediaController.releaseFuture(controllerFuture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val pollRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val c = controller
|
||||||
|
if (c != null && c.isPlaying) {
|
||||||
|
val resultFuture = c.requestVisualizerData()
|
||||||
|
resultFuture.addListener({
|
||||||
|
try {
|
||||||
|
val result = resultFuture.get()
|
||||||
|
if (result.resultCode == androidx.media3.session.SessionResult.RESULT_SUCCESS) {
|
||||||
|
val data = result.extras.getFloatArray(Keys.EXTRA_VISUALIZER_DATA)
|
||||||
|
if (data != null && data.isNotEmpty()) {
|
||||||
|
visualizerPref?.update(data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Custom command failed with result code: ${result.resultCode}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error fetching visualizer data", e)
|
||||||
|
}
|
||||||
|
}, MoreExecutors.directExecutor())
|
||||||
|
}
|
||||||
|
handler.postDelayed(this, 25) // ~40 FPS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
|
handler.removeCallbacks(pollRunnable)
|
||||||
|
handler.post(pollRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPolling() {
|
||||||
|
handler.removeCallbacks(pollRunnable)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ fun MediaController.requestSleepTimerRemaining(): ListenableFuture<SessionResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Request sleep timer remaining */
|
/* Request metadata history */
|
||||||
fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
|
fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
|
||||||
return sendCustomCommand(
|
return sendCustomCommand(
|
||||||
SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY),
|
SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY),
|
||||||
@@ -43,6 +43,14 @@ fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Request visualizer data */
|
||||||
|
fun MediaController.requestVisualizerData(): ListenableFuture<SessionResult> {
|
||||||
|
return sendCustomCommand(
|
||||||
|
SessionCommand(Keys.CMD_GET_VISUALIZER_DATA, Bundle.EMPTY),
|
||||||
|
Bundle.EMPTY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Starts playback with a new media item */
|
/* Starts playback with a new media item */
|
||||||
fun MediaController.play(context: Context, station: Station) {
|
fun MediaController.play(context: Context, station: Station) {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.michatec.radio.helpers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.SurfaceHolder
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceViewHolder
|
||||||
|
import com.michatec.radio.R
|
||||||
|
|
||||||
|
class ExtrasHelper {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ExtrasHelper"
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
System.loadLibrary("extra")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load extra library", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun visualize(surface: Surface, data: FloatArray)
|
||||||
|
|
||||||
|
fun render(surface: Surface, data: FloatArray) {
|
||||||
|
if (!surface.isValid) return
|
||||||
|
try {
|
||||||
|
visualize(surface, data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Native visualize failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VisualizerPreference(context: Context, attrs: AttributeSet? = null) : Preference(context, attrs) {
|
||||||
|
private var visualizerView: VisualizerView? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// We can use a standard layout and inject our view
|
||||||
|
layoutResource = R.layout.preference_visualizer
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||||
|
super.onBindViewHolder(holder)
|
||||||
|
|
||||||
|
// Try to find the container in the inflated layout
|
||||||
|
var container = holder.findViewById(R.id.visualizer_container) as? ViewGroup
|
||||||
|
|
||||||
|
// Fallback: If not found by ID, maybe the root is the container?
|
||||||
|
if (container == null && holder.itemView is ViewGroup) {
|
||||||
|
container = holder.itemView as ViewGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container != null) {
|
||||||
|
if (visualizerView == null) {
|
||||||
|
visualizerView = VisualizerView(context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentParent = visualizerView?.parent as? ViewGroup
|
||||||
|
if (currentParent != container) {
|
||||||
|
currentParent?.removeView(visualizerView)
|
||||||
|
// If we injected into a standard preference, don't clear everything, just add
|
||||||
|
if (container is FrameLayout || container.childCount == 0) {
|
||||||
|
container.removeAllViews()
|
||||||
|
}
|
||||||
|
container.addView(visualizerView)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e("VisualizerPreference", "Could not find any container to attach VisualizerView!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(data: FloatArray) {
|
||||||
|
visualizerView?.update(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VisualizerView @JvmOverloads constructor(
|
||||||
|
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||||
|
) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback {
|
||||||
|
|
||||||
|
private var surface: Surface? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
Log.d("VisualizerView", "VisualizerView initialized")
|
||||||
|
holder.addCallback(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(data: FloatArray) {
|
||||||
|
val s = surface
|
||||||
|
if (s != null && s.isValid) {
|
||||||
|
render(s, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
|
surface = holder.surface
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||||
|
surface = holder.surface
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
|
surface = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.michatec.radio.helpers
|
package com.michatec.radio.helpers
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.audio.AudioProcessor
|
import androidx.media3.common.audio.AudioProcessor
|
||||||
@@ -13,11 +14,18 @@ import java.nio.ByteOrder
|
|||||||
class NativeAudioProcessor : BaseAudioProcessor() {
|
class NativeAudioProcessor : BaseAudioProcessor() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "NativeAudioProcessor"
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("dsp")
|
try {
|
||||||
|
System.loadLibrary("dsp")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load dsp library", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var directBuffer: ByteBuffer? = null
|
||||||
|
|
||||||
// ===== JNI =====
|
// ===== JNI =====
|
||||||
private external fun setDrcEnabled(enabled: Boolean)
|
private external fun setDrcEnabled(enabled: Boolean)
|
||||||
private external fun setReverbMix(mix: Float)
|
private external fun setReverbMix(mix: Float)
|
||||||
@@ -37,7 +45,6 @@ class NativeAudioProcessor : BaseAudioProcessor() {
|
|||||||
fun enableBassBoost(gainDb: Float) = setBassBoost(gainDb)
|
fun enableBassBoost(gainDb: Float) = setBassBoost(gainDb)
|
||||||
fun setWidth(width: Float) = setStereoWidth(width)
|
fun setWidth(width: Float) = setStereoWidth(width)
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
fun getVisualizer(): FloatArray {
|
fun getVisualizer(): FloatArray {
|
||||||
val raw = getFftData()
|
val raw = getFftData()
|
||||||
val out = FloatArray(raw.size)
|
val out = FloatArray(raw.size)
|
||||||
@@ -47,8 +54,11 @@ class NativeAudioProcessor : BaseAudioProcessor() {
|
|||||||
|
|
||||||
// ===== AudioProcessor Overrides =====
|
// ===== AudioProcessor Overrides =====
|
||||||
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat {
|
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat {
|
||||||
if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT)
|
// Always try to support the input format if it is PCM 16-bit
|
||||||
|
if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) {
|
||||||
|
Log.e(TAG, "Unsupported encoding: ${inputAudioFormat.encoding}")
|
||||||
throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat)
|
throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat)
|
||||||
|
}
|
||||||
return inputAudioFormat
|
return inputAudioFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,20 +66,33 @@ class NativeAudioProcessor : BaseAudioProcessor() {
|
|||||||
val size = inputBuffer.remaining()
|
val size = inputBuffer.remaining()
|
||||||
if (size == 0) return
|
if (size == 0) return
|
||||||
|
|
||||||
// Direct ByteBuffer -> JNI
|
// Always ensure we have a direct buffer for JNI
|
||||||
inputBuffer.order(ByteOrder.nativeOrder())
|
if (directBuffer == null || directBuffer!!.capacity() < size) {
|
||||||
processAudioDirect(inputBuffer, size)
|
directBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder())
|
||||||
|
}
|
||||||
|
|
||||||
// Replace output buffer
|
directBuffer!!.clear()
|
||||||
|
inputBuffer.position()
|
||||||
|
directBuffer!!.put(inputBuffer)
|
||||||
|
|
||||||
|
directBuffer!!.flip()
|
||||||
|
|
||||||
|
// Process audio in JNI
|
||||||
|
processAudioDirect(directBuffer!!, size)
|
||||||
|
|
||||||
|
// Copy processed data back to output
|
||||||
val out = replaceOutputBuffer(size)
|
val out = replaceOutputBuffer(size)
|
||||||
out.order(ByteOrder.nativeOrder())
|
out.order(ByteOrder.nativeOrder())
|
||||||
|
|
||||||
// Mark as processed and copy to output
|
directBuffer!!.position(0)
|
||||||
val currentPos = inputBuffer.position()
|
out.put(directBuffer!!)
|
||||||
out.put(inputBuffer)
|
|
||||||
inputBuffer.position(currentPos + size)
|
|
||||||
|
|
||||||
out.flip()
|
out.flip()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReset() {
|
||||||
|
super.onReset()
|
||||||
|
directBuffer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Presets =====
|
// ===== Presets =====
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/visualizer_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#FF000000" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
<action
|
<action
|
||||||
android:id="@+id/action_settings_to_equalizer"
|
android:id="@+id/action_settings_to_equalizer"
|
||||||
app:destination="@id/equalizer_destination" />
|
app:destination="@id/equalizer_destination" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_settings_to_visualizer"
|
||||||
|
app:destination="@id/visualizer_destination" />
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
<!-- EQUALIZER -->
|
<!-- EQUALIZER -->
|
||||||
@@ -31,4 +34,9 @@
|
|||||||
android:name="com.michatec.radio.EqualizerFragment"
|
android:name="com.michatec.radio.EqualizerFragment"
|
||||||
android:label="Equalizer" />
|
android:label="Equalizer" />
|
||||||
|
|
||||||
|
<!-- VISUALIZER -->
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/visualizer_destination"
|
||||||
|
android:name="com.michatec.radio.VisualizerFragment"
|
||||||
|
android:label="Visualizer" />
|
||||||
</navigation>
|
</navigation>
|
||||||
|
|||||||
@@ -145,4 +145,5 @@
|
|||||||
<string name="pref_preset_pop">Pop</string>
|
<string name="pref_preset_pop">Pop</string>
|
||||||
<string name="pref_preset_jazz">Jazz</string>
|
<string name="pref_preset_jazz">Jazz</string>
|
||||||
<string name="pref_preset_flat">Flach</string>
|
<string name="pref_preset_flat">Flach</string>
|
||||||
|
<string name="pref_visualizer_title">Spektrumanzeige</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -173,4 +173,5 @@
|
|||||||
<!-- Extras -->
|
<!-- Extras -->
|
||||||
<string name="loading">Loading...</string>
|
<string name="loading">Loading...</string>
|
||||||
<string name="media_route_menu_title">Cast</string>
|
<string name="media_route_menu_title">Cast</string>
|
||||||
|
<string name="pref_visualizer_title">Spectrum Analyzer</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user