feat(ui): add spectrum analyzer visualizer

This commit is contained in:
2026-04-06 16:58:53 +02:00
parent 487195b716
commit 82993d7c97
14 changed files with 439 additions and 301 deletions
+6 -2
View File
@@ -26,13 +26,17 @@ project("radio")
# for GameActivity/NativeActivity derived applications, the same library name must be
# used in the AndroidManifest.xml file.
add_library(dsp SHARED
# List C/C++ source files with relative paths to this CMakeLists.txt.
dsp.cpp)
add_library(extra SHARED
extra.cpp)
# Specifies libraries CMake should link to your target library. You
# can link libraries from various origins, such as libraries defined in this
# build script, prebuilt third-party libraries, or Android system libraries.
target_link_libraries(dsp
# List libraries link to the target library
android
log)
target_link_libraries(extra
android
log)
+27 -281
View File
@@ -12,40 +12,22 @@
#define M_PI 3.14159265358979323846
#endif
// =============================================================================
// OPTIMIZED CONFIGURATION
// =============================================================================
// Use L1/L2 cache-optimized block size (typical L1: 32KB, L2: 256KB)
static constexpr int FFT_SIZE = 512;
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 SQRT_2_INV = 0.70710678f; // 1/sqrt(2)
// Denormal protection - use single scalar instead of adding per-sample
static constexpr float SQRT_2_INV = 0.70710678f;
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 = {
31.25f, 62.5f, 125.0f, 250.0f, 500.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 {
// Coefficients (SoA - better for SIMD loads)
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{};
// Active flags (packed into bitmask for branch-free processing)
uint16_t activeMask = 0;
// Pre-check if any EQ band is active - branch free
[[nodiscard]] inline bool hasActiveBands() const { return activeMask != 0; }
inline void setBandActive(int band, bool active) {
@@ -53,13 +35,10 @@ struct alignas(16) BiquadBank {
else activeMask &= ~(1 << band);
}
// Optimized bulk processing for a single channel
inline void processBlock(float* __restrict__ data, int count) {
if (!this -> hasActiveBands()) return;
for (int i = 0; i < count; i++) {
float x = data[i];
// Process all bands (compiler will optimize for activeMask)
#pragma GCC unroll 10
for (int b = 0; b < NUM_EQ_BANDS; 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) {
if (band < 0 || band >= NUM_EQ_BANDS) return;
const bool active = std::abs(g) > 0.1f;
setBandActive(band, active);
if (!active) return;
const float A = powf(10.0f, g / 40.0f);
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 c = cosf(w);
const float a0_raw = 1.0f + alpha / A;
const float invA0 = 1.0f / a0_raw;
a0[band] = (1.0f + alpha * A) * invA0;
a1[band] = (-2.0f * c) * invA0;
a2[band] = (1.0f - alpha * A) * invA0;
@@ -96,61 +71,23 @@ struct alignas(16) BiquadBank {
}
};
// =============================================================================
// BASS BOOST
// =============================================================================
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 z1 = 0.0f, z2 = 0.0f;
bool active = false;
BiquadBank myBank;
inline float process(float x) {
if (!active) return x;
float y = x * a0 + z1;
z1 = x * a1 + z2 - b1 * y + DENORMAL_OFFSET;
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;
}
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){
active=std::abs(g)>0.01f;
if(!active) return;
float A=powf(10.0f,g/40.0f);
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);
@@ -165,40 +102,24 @@ struct alignas(16) BassFilter {
}
};
// =============================================================================
// LOCK-FREE REVERB - Fixed-size circular buffers (no heap allocation)
// =============================================================================
template<int SIZE>
struct CircularBuffer {
alignas(16) std::array<float, SIZE> data = {};
int pos = 0;
[[nodiscard]] inline float read() const { return data[pos]; }
inline void write(float v) { data[pos] = v; }
inline void advance() { pos = (pos + 1) % SIZE; }
};
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<556>, 2> allpasses;
std::array<float, 4> combFeedback = {0.841f, 0.815f, 0.796f, 0.771f};
float mix = 0.0f;
public:
ReverbOptimized() = default;
inline void setMix(float m) { mix = m; }
// Branch-free processing with inline inlining
inline float process(float x) {
if (mix < 0.01f) return x;
// Parallel comb filters (unrolled for ARM NEON)
float out = 0.0f;
#pragma GCC unroll 4
for (int i = 0; i < 4; i++) {
@@ -207,9 +128,7 @@ public:
combs[i].write(x + delayed * combFeedback[i] + DENORMAL_OFFSET);
combs[i].advance();
}
out *= 0.25f; // 1/4 normalization
// Series allpass filters
out *= 0.25f;
for (int i = 0; i < 2; i++) {
float bufOut = allpasses[i].read();
float xOut = -0.5f * out + bufOut;
@@ -217,14 +136,10 @@ public:
allpasses[i].advance();
out = xOut;
}
return x * (1.0f - mix) + out * mix;
}
// NEON-optimized block processing
inline void processBlock(float* __restrict__ left, float* __restrict__ right, int count) {
if (mix < 0.01f) return;
for (int i = 0; i < count; i++) {
left[i] = process(left[i]);
right[i] = process(right[i]);
@@ -232,28 +147,13 @@ public:
}
};
// =============================================================================
// OPTIMIZED COMPRESSOR - Per-channel state, branch-free envelope
// =============================================================================
class CompressorOptimized {
public:
float threshold = 0.3f;
float ratio = 4.0f;
float attack = 0.08f;
float release = 0.8f;
float sampleRate = 44100.0f;
float threshold = 0.3f, ratio = 4.0f, attack = 0.08f, release = 0.8f, sampleRate = 44100.0f;
private:
// Per-channel envelope state
float envelopeL = 0.0f;
float envelopeR = 0.0f;
// Pre-computed coefficients
float attackCoef = 0.0f;
float releaseCoef = 0.0f;
float envelopeL = 0.0f, envelopeR = 0.0f;
float attackCoef = 0.0f, releaseCoef = 0.0f;
bool coefficientsValid = false;
public:
inline void updateCoefficients() {
if (coefficientsValid) return;
@@ -261,70 +161,41 @@ public:
releaseCoef = expf(-1.0f / (release * sampleRate));
coefficientsValid = true;
}
inline void processBlock(float* __restrict__ buffer, int count, float& envelope) {
updateCoefficients();
const int blockSize = 32;
for(int b=0;b<count;b+=blockSize){
int sz = (b+blockSize<count)? blockSize : count-b;
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;
for(int i=0; i<count; i++){
float absInput = fabsf(buffer[i]);
envelope = (absInput > envelope) ? attackCoef*envelope + (1-attackCoef)*absInput : releaseCoef*envelope + (1-releaseCoef)*absInput;
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) {
processBlock(left, count, envelopeL);
processBlock(right, count, envelopeR);
}
};
// =============================================================================
// GLOBAL ENGINE - SoA layout for cache efficiency
// =============================================================================
CompressorOptimized gCompressor;
ReverbOptimized gReverbL, gReverbR;
BiquadBank gEqL, gEqR;
BassFilter gBassL, gBassR;
// Global state flags
bool gDrcEnabled = false;
bool gEqEnabled = false; // Derived from gEqL.hasActiveBands()
bool gBassBoostEnabled = false;
bool gDrcEnabled = false, gEqEnabled = false, gBassBoostEnabled = false;
float gStereoWidth = 1.0f;
float gTargetRMS = 0.20f;
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, 4096> gLeftBuf, gRightBuf;
alignas(16) std::array<float, 256> gFFTData;
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) {
// Bit-reversal permutation (iterative, cache-friendly)
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) std::swap(data[i], data[j]);
}
// Cooley-Tukey stages
for (int len = 2; len <= n; len <<= 1) {
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));
for (int i = 0; i < n; i += len) {
std::complex<float> w(1.0f);
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) {
// Branchless clipping using min/max
float ax = fabsf(x);
float sign = x > 0 ? 1.0f : -1.0f;
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" {
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setDrcEnabled(JNIEnv*, jobject, jboolean 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_setDrcEnabled(JNIEnv*, jobject, jboolean 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_setEqBand(JNIEnv*, jobject, jint b, jfloat g) {
if (b >= 0 && b < NUM_EQ_BANDS) {
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();
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setBassBoost(JNIEnv*, jobject, jfloat g) {
if (g > 0.01f) {
gBassL.setLowShelf(44100.0f, 150.0f, g, SQRT_2_INV);
gBassR.setLowShelf(44100.0f, 150.0f, g, SQRT_2_INV);
gBassBoostEnabled = true;
} else {
gBassBoostEnabled = false;
}
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setStereoWidth(JNIEnv*, jobject, jfloat w) {
gStereoWidth = fmaxf(0.0f, fminf(w, 2.0f));
} else { 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 jfloatArray JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_getFftData(JNIEnv* env, jobject) {
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) {
auto* buffer = static_cast<jshort*>(env->GetDirectBufferAddress(byteBuffer));
if (!buffer) return;
int numFrames = (size / 2) / 2;
if (numFrames > 4096) numFrames = 4096; // Clamp to buffer size
if (numFrames > 4096) numFrames = 4096;
// =========================================================================
// 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++) {
for (int i = 0; i < numFrames; i++) {
gLeftBuf[i] = static_cast<float>(buffer[i * 2]) * INV_32768;
gRightBuf[i] = static_cast<float>(buffer[i * 2 + 1]) * INV_32768;
}
// =========================================================================
// 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 (gEqEnabled) { gEqL.processBlock(gLeftBuf.data(), numFrames); gEqR.processBlock(gRightBuf.data(), numFrames); }
if (gBassBoostEnabled) {
gBassL.processNEON(gLeftBuf.data(), numFrames);
gBassR.processNEON(gRightBuf.data(), numFrames);
applyRMSLimit(gLeftBuf.data(), numFrames);
applyRMSLimit(gRightBuf.data(), numFrames);
for(int i=0; i<numFrames; i++) { gLeftBuf[i] = gBassL.process(gLeftBuf[i]); gRightBuf[i] = gBassR.process(gRightBuf[i]); }
}
// Reverb
gReverbL.processBlock(gLeftBuf.data(), gRightBuf.data(), numFrames);
// Stereo width processing (branch-free)
if (gStereoWidth != 1.0f) {
float halfWidth = gStereoWidth * 0.5f;
for (int j = 0; j < numFrames; j++) {
float mid = (gLeftBuf[j] + gRightBuf[j]) * 0.5f;
float side = (gLeftBuf[j] - gRightBuf[j]) * halfWidth;
gLeftBuf[j] = mid + side;
gRightBuf[j] = mid - side;
gLeftBuf[j] = mid + side; gRightBuf[j] = mid - side;
}
}
// =========================================================================
// STAGE 3: Dynamic Control (AutoGain -> Compressor)
// =========================================================================
applyAutoGain(gLeftBuf.data(), numFrames);
applyAutoGain(gRightBuf.data(), numFrames);
if (gDrcEnabled) gCompressor.process(gLeftBuf.data(), gRightBuf.data(), numFrames);
if (gDrcEnabled) {
gCompressor.process(gLeftBuf.data(), gRightBuf.data(), numFrames);
}
// =========================================================================
// STAGE 4: FFT Analysis (downsampled for visualization)
// =========================================================================
// Zero-pad for FFT (use first 256 samples only)
// FFT for visualization
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);
// Compute magnitude spectrum (only first 256 bins)
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++) {
buffer[k * 2] = static_cast<jshort>(fastSoftClip(gLeftBuf[k]) * 32767.0f);
buffer[k * 2 + 1] = static_cast<jshort>(fastSoftClip(gRightBuf[k]) * 32767.0f);
}
}
} // extern "C"
}
+70
View File
@@ -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_SLEEP_TIMER_REMAINING: String = "SLEEP_TIMER_REMAINING"
const val EXTRA_METADATA_HISTORY: String = "METADATA_HISTORY"
const val EXTRA_VISUALIZER_DATA: String = "VISUALIZER_DATA"
// arguments
const val ARG_UPDATE_COLLECTION: String = "ArgUpdateCollection"
@@ -43,6 +44,7 @@ object Keys {
const val CMD_PLAY_STREAM: String = "PLAY_STREAM"
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_GET_VISUALIZER_DATA: String = "GET_VISUALIZER_DATA"
// preferences
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_REQUEST_SLEEP_TIMER_REMAINING, 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)
}
@@ -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)
}
@@ -250,6 +250,14 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
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
val preferenceAppVersion = Preference(context)
preferenceAppVersion.title = getString(R.string.pref_app_version_title)
@@ -299,7 +307,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
// set preference categories
val preferenceCategoryGeneral = PreferenceCategory(activity as Context)
preferenceCategoryGeneral.title = getString(R.string.pref_general_title)
val preferenceCategoryAudioEffects = PreferenceCategory(context)
preferenceCategoryAudioEffects.title = getString(R.string.pref_audio_effects_title)
@@ -328,6 +336,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
preferenceCategoryAudioEffects.addPreference(preferenceDrc)
preferenceCategoryAudioEffects.addPreference(preferencePresetSelection)
preferenceCategoryAudioEffects.addPreference(preferenceEqualizer)
preferenceCategoryAudioEffects.addPreference(preferenceVisualizer)
screen.addPreference(preferenceCategoryMaintenance)
preferenceCategoryMaintenance.addPreference(preferenceUpdateStationImages)
@@ -394,13 +403,13 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
// Update Bass Boost
findPreference<Preference>(Keys.PREF_BASS_BOOST)?.isEnabled = !isPresetSelected
// Update Reverb
findPreference<Preference>(Keys.PREF_REVERB)?.isEnabled = !isPresetSelected
// Update DRC
findPreference<Preference>(Keys.PREF_DRC)?.isEnabled = !isPresetSelected
// Update Equalizer with proper key
val preferenceEqualizer = findPreference<Preference>(Keys.PREF_EQUALIZER)
if (preferenceEqualizer != null) {
@@ -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> {
return sendCustomCommand(
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 */
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
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.audio.AudioProcessor
@@ -13,11 +14,18 @@ import java.nio.ByteOrder
class NativeAudioProcessor : BaseAudioProcessor() {
companion object {
private const val TAG = "NativeAudioProcessor"
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 =====
private external fun setDrcEnabled(enabled: Boolean)
private external fun setReverbMix(mix: Float)
@@ -37,7 +45,6 @@ class NativeAudioProcessor : BaseAudioProcessor() {
fun enableBassBoost(gainDb: Float) = setBassBoost(gainDb)
fun setWidth(width: Float) = setStereoWidth(width)
@Suppress("unused")
fun getVisualizer(): FloatArray {
val raw = getFftData()
val out = FloatArray(raw.size)
@@ -47,8 +54,11 @@ class NativeAudioProcessor : BaseAudioProcessor() {
// ===== AudioProcessor Overrides =====
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)
}
return inputAudioFormat
}
@@ -56,20 +66,33 @@ class NativeAudioProcessor : BaseAudioProcessor() {
val size = inputBuffer.remaining()
if (size == 0) return
// Direct ByteBuffer -> JNI
inputBuffer.order(ByteOrder.nativeOrder())
processAudioDirect(inputBuffer, size)
// Always ensure we have a direct buffer for JNI
if (directBuffer == null || directBuffer!!.capacity() < size) {
directBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder())
}
directBuffer!!.clear()
inputBuffer.position()
directBuffer!!.put(inputBuffer)
directBuffer!!.flip()
// Process audio in JNI
processAudioDirect(directBuffer!!, size)
// Replace output buffer
// Copy processed data back to output
val out = replaceOutputBuffer(size)
out.order(ByteOrder.nativeOrder())
// Mark as processed and copy to output
val currentPos = inputBuffer.position()
out.put(inputBuffer)
inputBuffer.position(currentPos + size)
directBuffer!!.position(0)
out.put(directBuffer!!)
out.flip()
}
override fun onReset() {
super.onReset()
directBuffer = null
}
// ===== 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
android:id="@+id/action_settings_to_equalizer"
app:destination="@id/equalizer_destination" />
<action
android:id="@+id/action_settings_to_visualizer"
app:destination="@id/visualizer_destination" />
</fragment>
<!-- EQUALIZER -->
@@ -31,4 +34,9 @@
android:name="com.michatec.radio.EqualizerFragment"
android:label="Equalizer" />
<!-- VISUALIZER -->
<fragment
android:id="@+id/visualizer_destination"
android:name="com.michatec.radio.VisualizerFragment"
android:label="Visualizer" />
</navigation>
+1
View File
@@ -145,4 +145,5 @@
<string name="pref_preset_pop">Pop</string>
<string name="pref_preset_jazz">Jazz</string>
<string name="pref_preset_flat">Flach</string>
<string name="pref_visualizer_title">Spektrumanzeige</string>
</resources>
+1
View File
@@ -173,4 +173,5 @@
<!-- Extras -->
<string name="loading">Loading...</string>
<string name="media_route_menu_title">Cast</string>
<string name="pref_visualizer_title">Spectrum Analyzer</string>
</resources>