From 82993d7c9705b87853c9b411eb7a1f5815dbdf18 Mon Sep 17 00:00:00 2001 From: Michatec Date: Mon, 6 Apr 2026 16:58:53 +0200 Subject: [PATCH] feat(ui): add spectrum analyzer visualizer --- app/src/main/cpp/CMakeLists.txt | 8 +- app/src/main/cpp/dsp.cpp | 308 ++---------------- app/src/main/cpp/extra.cpp | 70 ++++ app/src/main/java/com/michatec/radio/Keys.kt | 2 + .../java/com/michatec/radio/PlayerService.kt | 14 + .../com/michatec/radio/SettingsFragment.kt | 17 +- .../com/michatec/radio/VisualizerFragment.kt | 122 +++++++ .../radio/extensions/MediaControllerExt.kt | 10 +- .../michatec/radio/helpers/ExtrasHelper.kt | 117 +++++++ .../radio/helpers/NativeAudioProcessor.kt | 49 ++- .../main/res/layout/preference_visualizer.xml | 13 + .../main/res/navigation/nav_graph_main.xml | 8 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 14 files changed, 439 insertions(+), 301 deletions(-) create mode 100644 app/src/main/cpp/extra.cpp create mode 100644 app/src/main/java/com/michatec/radio/VisualizerFragment.kt create mode 100644 app/src/main/java/com/michatec/radio/helpers/ExtrasHelper.kt create mode 100644 app/src/main/res/layout/preference_visualizer.xml diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 81e6daf..c31b8b4 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -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) \ No newline at end of file diff --git a/app/src/main/cpp/dsp.cpp b/app/src/main/cpp/dsp.cpp index cc551d2..9bd1733 100644 --- a/app/src/main/cpp/dsp.cpp +++ b/app/src/main/cpp/dsp.cpp @@ -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 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 a0{}, a1{}, a2{}, b1{}, b2{}; - // State variables alignas(16) std::array 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(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 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(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 struct CircularBuffer { alignas(16) std::array 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, 4> combs; std::array, 2> allpasses; std::array 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;bmaxVal) maxVal = absInput; - } - bool attackMode = maxVal > envelope; - envelope = attackMode ? attackCoef*envelope + (1-attackCoef)*maxVal - : releaseCoef*envelope + (1-releaseCoef)*maxVal; + for(int i=0; i 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 gLeftBuf; -alignas(16) std::array gRightBuf; +alignas(16) std::array gLeftBuf, gRightBuf; alignas(16) std::array gFFTData; alignas(16) std::array, FFT_SIZE> gFFTWork; -// Fast FFT - iterative Cooley-Tukey inline void fastFFT(std::complex* __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(M_PI) / static_cast(len); - // Pre-compute wlen - critical for performance std::complex wlen(cosf(ang), sinf(ang)); - for (int i = 0; i < n; i += len) { std::complex w(1.0f); for (int j = 0; j < len / 2; j++) { @@ -338,83 +209,17 @@ inline void fastFFT(std::complex* __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(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 0.8f){ - float scale = 0.8f / rms; - for(int i=0;i= 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(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(buffer[i * 2]) * INV_32768; gRightBuf[i] = static_cast(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 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(gLeftBuf[k], 0.0f) : std::complex(0.0f, 0.0f); + gFFTWork[k] = (k < 256 && k < numFrames) ? std::complex(gLeftBuf[k], 0.0f) : std::complex(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(fastSoftClip(gLeftBuf[k]) * 32767.0f); buffer[k * 2 + 1] = static_cast(fastSoftClip(gRightBuf[k]) * 32767.0f); } } - -} // extern "C" \ No newline at end of file +} diff --git a/app/src/main/cpp/extra.cpp b/app/src/main/cpp/extra.cpp new file mode 100644 index 0000000..e0c6ef2 --- /dev/null +++ b/app/src/main/cpp/extra.cpp @@ -0,0 +1,70 @@ +#include +#include +#include +#include +#include +#include + + +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(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(len), 128); + float barWidth = static_cast(buffer.width) / static_cast(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(scaledVal * static_cast(buffer.height)); + + if (barHeight > buffer.height) barHeight = buffer.height; + if (barHeight < 12) barHeight = 12; // Min height + + int startX = static_cast(static_cast(i) * barWidth); + int endX = static_cast(static_cast(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" diff --git a/app/src/main/java/com/michatec/radio/Keys.kt b/app/src/main/java/com/michatec/radio/Keys.kt index 4fd9134..e01a0c3 100644 --- a/app/src/main/java/com/michatec/radio/Keys.kt +++ b/app/src/main/java/com/michatec/radio/Keys.kt @@ -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" diff --git a/app/src/main/java/com/michatec/radio/PlayerService.kt b/app/src/main/java/com/michatec/radio/PlayerService.kt index fd5ae5e..f64ac3c 100644 --- a/app/src/main/java/com/michatec/radio/PlayerService.kt +++ b/app/src/main/java/com/michatec/radio/PlayerService.kt @@ -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) } diff --git a/app/src/main/java/com/michatec/radio/SettingsFragment.kt b/app/src/main/java/com/michatec/radio/SettingsFragment.kt index 454248e..7bc6fc0 100644 --- a/app/src/main/java/com/michatec/radio/SettingsFragment.kt +++ b/app/src/main/java/com/michatec/radio/SettingsFragment.kt @@ -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(Keys.PREF_BASS_BOOST)?.isEnabled = !isPresetSelected - + // Update Reverb findPreference(Keys.PREF_REVERB)?.isEnabled = !isPresetSelected - + // Update DRC findPreference(Keys.PREF_DRC)?.isEnabled = !isPresetSelected - + // Update Equalizer with proper key val preferenceEqualizer = findPreference(Keys.PREF_EQUALIZER) if (preferenceEqualizer != null) { diff --git a/app/src/main/java/com/michatec/radio/VisualizerFragment.kt b/app/src/main/java/com/michatec/radio/VisualizerFragment.kt new file mode 100644 index 0000000..4bae001 --- /dev/null +++ b/app/src/main/java/com/michatec/radio/VisualizerFragment.kt @@ -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 + 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) + } +} diff --git a/app/src/main/java/com/michatec/radio/extensions/MediaControllerExt.kt b/app/src/main/java/com/michatec/radio/extensions/MediaControllerExt.kt index 979d591..8b971d4 100644 --- a/app/src/main/java/com/michatec/radio/extensions/MediaControllerExt.kt +++ b/app/src/main/java/com/michatec/radio/extensions/MediaControllerExt.kt @@ -35,7 +35,7 @@ fun MediaController.requestSleepTimerRemaining(): ListenableFuture { return sendCustomCommand( SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY), @@ -43,6 +43,14 @@ fun MediaController.requestMetadataHistory(): ListenableFuture { ) } +/* Request visualizer data */ +fun MediaController.requestVisualizerData(): ListenableFuture { + 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) { diff --git a/app/src/main/java/com/michatec/radio/helpers/ExtrasHelper.kt b/app/src/main/java/com/michatec/radio/helpers/ExtrasHelper.kt new file mode 100644 index 0000000..d46f10a --- /dev/null +++ b/app/src/main/java/com/michatec/radio/helpers/ExtrasHelper.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/michatec/radio/helpers/NativeAudioProcessor.kt b/app/src/main/java/com/michatec/radio/helpers/NativeAudioProcessor.kt index 5168d43..ed7b20c 100644 --- a/app/src/main/java/com/michatec/radio/helpers/NativeAudioProcessor.kt +++ b/app/src/main/java/com/michatec/radio/helpers/NativeAudioProcessor.kt @@ -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 ===== diff --git a/app/src/main/res/layout/preference_visualizer.xml b/app/src/main/res/layout/preference_visualizer.xml new file mode 100644 index 0000000..175a8d4 --- /dev/null +++ b/app/src/main/res/layout/preference_visualizer.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph_main.xml b/app/src/main/res/navigation/nav_graph_main.xml index 1d78b1a..51dd6b4 100644 --- a/app/src/main/res/navigation/nav_graph_main.xml +++ b/app/src/main/res/navigation/nav_graph_main.xml @@ -23,6 +23,9 @@ + @@ -31,4 +34,9 @@ android:name="com.michatec.radio.EqualizerFragment" android:label="Equalizer" /> + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 599ec3d..280dd2f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -145,4 +145,5 @@ Pop Jazz Flach + Spektrumanzeige diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1000a6b..51772a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -173,4 +173,5 @@ Loading... Cast + Spectrum Analyzer