7 Commits

21 changed files with 1275 additions and 289 deletions
+10 -6
View File
@@ -18,21 +18,25 @@ project("radio")
# Gradle automatically packages shared libraries with your APK.
#
# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
# the target library name; in the sub-module's CMakeLists.txt, the project name
# is preferred for the same purpose.
#
# In order to load a library into your app from Java/Kotlin, you must call
# System.loadLibrary() and pass the name of the library defined here;
# for GameActivity/NativeActivity derived applications, the same library name must be
# used in the AndroidManifest.xml file.
add_library(${CMAKE_PROJECT_NAME} SHARED
# List C/C++ source files with relative paths to this CMakeLists.txt.
radio.cpp)
add_library(dsp SHARED
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(${CMAKE_PROJECT_NAME}
# List libraries link to the target library
target_link_libraries(dsp
android
log)
target_link_libraries(extra
android
log)
+287
View File
@@ -0,0 +1,287 @@
#include <jni.h>
#include <vector>
#include <cmath>
#include <complex>
#include <array>
#if defined(__ARM_NEON)
#include <arm_neon.h>
#endif
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static constexpr int FFT_SIZE = 512;
static constexpr int NUM_EQ_BANDS = 10;
static constexpr float INV_32768 = 1.0f / 32768.0f;
static constexpr float SQRT_2_INV = 0.70710678f;
static constexpr float DENORMAL_OFFSET = 1e-18f;
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
};
struct alignas(16) BiquadBank {
alignas(16) std::array<float, NUM_EQ_BANDS> a0{}, a1{}, a2{}, b1{}, b2{};
alignas(16) std::array<float, NUM_EQ_BANDS> z1{}, z2{};
uint16_t activeMask = 0;
[[nodiscard]] inline bool hasActiveBands() const { return activeMask != 0; }
inline void setBandActive(int band, bool active) {
if (active) activeMask |= (1 << band);
else activeMask &= ~(1 << band);
}
inline void processBlock(float* __restrict__ data, int count) {
if (!this -> hasActiveBands()) return;
for (int i = 0; i < count; i++) {
float x = data[i];
#pragma GCC unroll 10
for (int b = 0; b < NUM_EQ_BANDS; b++) {
if (activeMask & (1 << b)) {
float y = x * a0[b] + z1[b];
z1[b] = x * a1[b] + z2[b] - b1[b] * y + DENORMAL_OFFSET;
z2[b] = x * a2[b] - b2[b] * y;
x = y;
}
}
data[i] = x;
}
}
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;
b1[band] = (-2.0f * c) * invA0;
b2[band] = (1.0f - alpha / A) * invA0;
}
};
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;
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;
if(y > 1.2f) y = 1.2f; else if(y < -1.2f) y = -1.2f;
return y;
}
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);
float c=cosf(w),sqrtA=sqrtf(A);
float a0_raw=(A+1.0f)+(A-1.0f)*c+2.0f*sqrtA*alpha;
float invA0=1.0f/a0_raw;
a0=A*((A+1.0f)-(A-1.0f)*c+2.0f*sqrtA*alpha)*invA0;
a1=2.0f*A*((A-1.0f)-(A+1.0f)*c)*invA0;
a2=A*((A+1.0f)-(A-1.0f)*c-2.0f*sqrtA*alpha)*invA0;
b1=-2.0f*((A-1.0f)+(A+1.0f)*c)*invA0;
b2=((A+1.0f)+(A-1.0f)*c-2.0f*sqrtA*alpha)*invA0;
}
};
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 {
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:
inline void setMix(float m) { mix = m; }
inline float process(float x) {
if (mix < 0.01f) return x;
float out = 0.0f;
#pragma GCC unroll 4
for (int i = 0; i < 4; i++) {
float delayed = combs[i].read();
out += delayed;
combs[i].write(x + delayed * combFeedback[i] + DENORMAL_OFFSET);
combs[i].advance();
}
out *= 0.25f;
for (int i = 0; i < 2; i++) {
float bufOut = allpasses[i].read();
float xOut = -0.5f * out + bufOut;
allpasses[i].write(out + 0.5f * bufOut);
allpasses[i].advance();
out = xOut;
}
return x * (1.0f - mix) + out * mix;
}
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]);
}
}
};
class CompressorOptimized {
public:
float threshold = 0.3f, ratio = 4.0f, attack = 0.08f, release = 0.8f, sampleRate = 44100.0f;
private:
float envelopeL = 0.0f, envelopeR = 0.0f;
float attackCoef = 0.0f, releaseCoef = 0.0f;
bool coefficientsValid = false;
public:
inline void updateCoefficients() {
if (coefficientsValid) return;
attackCoef = expf(-1.0f / (attack * sampleRate));
releaseCoef = expf(-1.0f / (release * sampleRate));
coefficientsValid = true;
}
inline void processBlock(float* __restrict__ buffer, int count, float& envelope) {
updateCoefficients();
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;
buffer[i]*=gain;
}
}
inline void process(float* __restrict__ left, float* __restrict__ right, int count) {
processBlock(left, count, envelopeL);
processBlock(right, count, envelopeR);
}
};
CompressorOptimized gCompressor;
ReverbOptimized gReverbL, gReverbR;
BiquadBank gEqL, gEqR;
BassFilter gBassL, gBassR;
bool gDrcEnabled = false, gEqEnabled = false, gBassBoostEnabled = false;
float gStereoWidth = 1.0f;
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;
inline void fastFFT(std::complex<float>* __restrict__ data, int n) {
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]);
}
for (int len = 2; len <= n; len <<= 1) {
float ang = -2.0f * static_cast<float>(M_PI) / static_cast<float>(len);
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++) {
std::complex<float> u = data[i + j];
std::complex<float> v = data[i + j + len / 2] * w;
data[i + j] = u + v;
data[i + j + len / 2] = u - v;
w *= wlen;
}
}
}
}
inline float fastSoftClip(float x) {
float ax = fabsf(x);
float sign = x > 0 ? 1.0f : -1.0f;
if (ax > 1.0f) return sign;
return x * (1.5f - 0.5f * x * x);
}
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_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);
gEqR.setPeakingEQ(b, 44100.0f, EQ_FREQUENCIES[b], g, 1.0f);
}
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)); }
JNIEXPORT jfloatArray JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_getFftData(JNIEnv* env, jobject) {
jfloatArray arr = env->NewFloatArray(256);
env->SetFloatArrayRegion(arr, 0, 256, gFFTData.data());
return arr;
}
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;
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;
}
if (gEqEnabled) { gEqL.processBlock(gLeftBuf.data(), numFrames); gEqR.processBlock(gRightBuf.data(), numFrames); }
if (gBassBoostEnabled) {
for(int i=0; i<numFrames; i++) { gLeftBuf[i] = gBassL.process(gLeftBuf[i]); gRightBuf[i] = gBassR.process(gRightBuf[i]); }
}
gReverbL.processBlock(gLeftBuf.data(), gRightBuf.data(), numFrames);
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;
}
}
if (gDrcEnabled) gCompressor.process(gLeftBuf.data(), gRightBuf.data(), numFrames);
// FFT for visualization
for (int k = 0; k < FFT_SIZE; k++) {
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);
for (int k = 0; k < 256; k++) {
gFFTData[k] = std::abs(gFFTWork[k]) * 0.5f; // Increased scale
}
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);
}
}
}
+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"
-208
View File
@@ -1,208 +0,0 @@
#include <jni.h>
#include <string>
#include <vector>
#include <cmath>
#include <algorithm>
#include <android/log.h>
// --- DSP Classes ---
/**
* Biquad Filter for EQ and Shelving
*/
class Biquad {
public:
float a0 = 1.0f, a1 = 0.0f, a2 = 0.0f, b1 = 0.0f, b2 = 0.0f;
float z1 = 0.0f, z2 = 0.0f;
void setPeakingEQ(float sampleRate, float freq, float gainDb, float bandwidth) {
float a = powf(10.0f, gainDb / 40.0f);
float w0 = 2.0f * static_cast<float>(M_PI) * freq / sampleRate;
float alpha = sinf(w0) * sinhf(logf(2.0f) / 2.0f * bandwidth * w0 / sinf(w0));
float b0 = 1.0f + alpha * a;
a1 = -2.0f * cosf(w0);
a2 = 1.0f - alpha * a;
float b0_inv = 1.0f / (1.0f + alpha / a);
b1 = -2.0f * cosf(w0) * b0_inv;
b2 = (1.0f - alpha / a) * b0_inv;
a0 = b0 * b0_inv;
a1 *= b0_inv;
a2 *= b0_inv;
}
void setLowShelf(float sampleRate, float frequency, float gainDb, float q) {
float a = powf(10.0f, gainDb / 40.0f);
float w0 = 2.0f * static_cast<float>(M_PI) * frequency / sampleRate;
float alpha = sinf(w0) / 2.0f * sqrtf((a + 1.0f / a) * (1.0f / q - 1.0f) + 2.0f);
float cosW0 = cosf(w0);
float b0 = a * ((a + 1.0f) - (a - 1.0f) * cosW0 + 2.0f * sqrtf(a) * alpha);
a1 = 2.0f * a * ((a - 1.0f) - (a + 1.0f) * cosW0);
a2 = a * ((a + 1.0f) - (a - 1.0f) * cosW0 - 2.0f * sqrtf(a) * alpha);
float b0_inv = 1.0f / ((a + 1.0f) + (a - 1.0f) * cosW0 + 2.0f * sqrtf(a) * alpha);
b1 = -2.0f * ((a - 1.0f) + (a + 1.0f) * cosW0) * b0_inv;
b2 = ((a + 1.0f) + (a - 1.0f) * cosW0 - 2.0f * sqrtf(a) * alpha) * b0_inv;
a0 = b0 * b0_inv;
a1 *= b0_inv;
a2 *= b0_inv;
}
float process(float in) {
float out = in * a0 + z1;
z1 = in * a1 + z2 - b1 * out;
z2 = in * a2 - b2 * out;
return out;
}
};
/**
* Dynamic Range Compressor
*/
class Compressor {
public:
float threshold = 0.3f;
float ratio = 4.0f;
float attack = 0.01f;
float release = 0.2f;
float sampleRate = 44100.0f;
float envelope = 0.0f;
void process(float* buffer, int size) {
float attackCoef = expf(-1.0f / (attack * sampleRate));
float releaseCoef = expf(-1.0f / (release * sampleRate));
for (int i = 0; i < size; ++i) {
float absInput = std::abs(buffer[i]);
if (absInput > envelope)
envelope = attackCoef * (envelope - absInput) + absInput;
else
envelope = releaseCoef * (envelope - absInput) + absInput;
if (envelope > threshold) {
float gainReduction = threshold + (envelope - threshold) / ratio;
buffer[i] *= (gainReduction / envelope);
}
}
}
};
/**
* Simple Reverb (Comb Filter based)
*/
class Reverb {
public:
std::vector<float> d1, d2, d3;
size_t p1 = 0, p2 = 0, p3 = 0;
float feedback = 0.7f;
float mix = 0.0f;
Reverb() {
d1.resize(11025, 0.0f); // 250ms
d2.resize(14700, 0.0f); // 333ms
d3.resize(17640, 0.0f); // 400ms
}
float process(float in) {
float y1 = d1[p1];
float y2 = d2[p2];
float y3 = d3[p3];
d1[p1] = in + y1 * feedback;
d2[p2] = in + y2 * feedback;
d3[p3] = in + y3 * feedback;
p1 = (p1 + 1) % d1.size();
p2 = (p2 + 1) % d2.size();
p3 = (p3 + 1) % d3.size();
float reverb = (y1 + y2 + y3) / 3.0f;
return in * (1.0f - mix) + reverb * mix;
}
};
// --- Global Engine State ---
Compressor gCompressor;
Reverb gReverb;
std::vector<Biquad> gEqBands(10);
Biquad gBassBoost;
bool gDrcEnabled = false;
bool gReverbEnabled = false;
bool gEqEnabled = false;
bool gBassBoostEnabled = false;
extern "C" {
JNIEXPORT void JNICALL
Java_com_michatec_radio_helpers_NativeAudioProcessor_setDrcEnabled(JNIEnv *env, jobject thiz, jboolean enabled) {
gDrcEnabled = enabled;
}
JNIEXPORT void JNICALL
Java_com_michatec_radio_helpers_NativeAudioProcessor_setReverbMix(JNIEnv *env, jobject thiz, jfloat mix) {
gReverb.mix = mix;
gReverbEnabled = (mix > 0.01f);
}
JNIEXPORT void JNICALL
Java_com_michatec_radio_helpers_NativeAudioProcessor_setEqBand(JNIEnv *env, jobject thiz, jint band, jfloat gainDb) {
float freqs[] = {31.25f, 62.5f, 125.0f, 250.0f, 500.0f, 1000.0f, 2000.0f, 4000.0f, 8000.0f, 16000.0f};
if (band >= 0 && band < 10) {
gEqBands[static_cast<size_t>(band)].setPeakingEQ(44100.0f, freqs[band], gainDb, 1.0f);
gEqEnabled = true;
}
}
JNIEXPORT void JNICALL
Java_com_michatec_radio_helpers_NativeAudioProcessor_setBassBoost(JNIEnv *env, jobject thiz, jfloat gainDb) {
if (gainDb > 0.0f) {
gBassBoost.setLowShelf(44100.0f, 150.0f, gainDb, 0.707f);
gBassBoostEnabled = true;
} else {
gBassBoostEnabled = false;
}
}
JNIEXPORT void JNICALL
Java_com_michatec_radio_helpers_NativeAudioProcessor_processAudio(JNIEnv *env, jobject thiz, jshortArray data, jint size) {
jshort *buffer = env->GetShortArrayElements(data, nullptr);
if (!buffer) return;
std::vector<float> floatBuf(static_cast<size_t>(size));
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = static_cast<float>(buffer[i]) / 32768.0f;
// Apply EQ
if (gEqEnabled) {
for (auto &band : gEqBands) {
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = band.process(floatBuf[static_cast<size_t>(i)]);
}
}
// Apply Bass Boost
if (gBassBoostEnabled) {
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = gBassBoost.process(floatBuf[static_cast<size_t>(i)]);
}
// Apply Reverb
if (gReverbEnabled) {
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = gReverb.process(floatBuf[static_cast<size_t>(i)]);
}
// Apply Compressor (at the end to prevent clipping)
if (gDrcEnabled) {
gCompressor.process(floatBuf.data(), size);
}
// Back to short
for (int i = 0; i < size; ++i) {
float out = std::max(-1.0f, std::min(1.0f, floatBuf[static_cast<size_t>(i)]));
buffer[i] = static_cast<jshort>(out * 32767.0f);
}
env->ReleaseShortArrayElements(data, buffer, 0);
}
} // extern "C"
@@ -9,10 +9,25 @@ import androidx.preference.SeekBarPreference
import com.michatec.radio.helpers.PreferencesHelper
/*
* EqualizerFragment class: Handles audio frequency settings
* EqualizerFragment class: Handles audio frequency settings with 10-band EQ
*/
class EqualizerFragment : PreferenceFragmentCompat() {
// EQ band frequencies matching radio.cpp
private val eqFrequencies = arrayOf("31 Hz", "62 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz")
private val eqKeys = arrayOf(
Keys.PREF_EQ_LOW, // Band 0: 31 Hz
Keys.PREF_EQ_BAND_1, // Band 1: 62 Hz
Keys.PREF_EQ_BAND_2, // Band 2: 125 Hz
Keys.PREF_EQ_BAND_3, // Band 3: 250 Hz
Keys.PREF_EQ_BAND_4, // Band 4: 500 Hz
Keys.PREF_EQ_BAND_5, // Band 5: 1 kHz
Keys.PREF_EQ_MID, // Band 6: 2 kHz
Keys.PREF_EQ_BAND_6, // Band 7: 4 kHz
Keys.PREF_EQ_BAND_7, // Band 8: 8 kHz
Keys.PREF_EQ_HIGH // Band 9: 16 kHz
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(activity as AppCompatActivity).supportActionBar?.title = getString(R.string.pref_equalizer_title)
@@ -28,46 +43,25 @@ class EqualizerFragment : PreferenceFragmentCompat() {
resetPreference.setIcon(R.drawable.ic_refresh_24dp)
resetPreference.setOnPreferenceClickListener {
PreferencesHelper.resetEqualizer()
// Manually update SeekBars to 0
findPreference<SeekBarPreference>(Keys.PREF_EQ_LOW)?.value = 0
findPreference<SeekBarPreference>(Keys.PREF_EQ_MID)?.value = 0
findPreference<SeekBarPreference>(Keys.PREF_EQ_HIGH)?.value = 0
for (key in eqKeys) {
findPreference<SeekBarPreference>(key)?.value = 0
}
return@setOnPreferenceClickListener true
}
screen.addPreference(resetPreference)
// EQ Low
val eqLow = SeekBarPreference(context)
eqLow.title = getString(R.string.pref_eq_low_title)
eqLow.key = Keys.PREF_EQ_LOW
eqLow.setIcon(R.drawable.ic_music_note_24dp)
eqLow.min = -12
eqLow.max = 12
eqLow.showSeekBarValue = true
eqLow.setDefaultValue(0)
screen.addPreference(eqLow)
// EQ Mid
val eqMid = SeekBarPreference(context)
eqMid.title = getString(R.string.pref_eq_mid_title)
eqMid.key = Keys.PREF_EQ_MID
eqMid.setIcon(R.drawable.ic_music_note_24dp)
eqMid.min = -12
eqMid.max = 12
eqMid.showSeekBarValue = true
eqMid.setDefaultValue(0)
screen.addPreference(eqMid)
// EQ High
val eqHigh = SeekBarPreference(context)
eqHigh.title = getString(R.string.pref_eq_high_title)
eqHigh.key = Keys.PREF_EQ_HIGH
eqHigh.setIcon(R.drawable.ic_music_note_24dp)
eqHigh.min = -12
eqHigh.max = 12
eqHigh.showSeekBarValue = true
eqHigh.setDefaultValue(0)
screen.addPreference(eqHigh)
// Create 10-band EQ
for (i in eqKeys.indices) {
val eqBand = SeekBarPreference(context)
eqBand.title = "Equalizer: ${eqFrequencies[i]}"
eqBand.key = eqKeys[i]
eqBand.setIcon(R.drawable.ic_music_note_24dp)
eqBand.min = -12
eqBand.max = 12
eqBand.showSeekBarValue = true
eqBand.setDefaultValue(0)
screen.addPreference(eqBand)
}
preferenceScreen = screen
}
@@ -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"
@@ -66,6 +68,30 @@ object Keys {
const val PREF_EQ_LOW: String = "EQ_LOW"
const val PREF_EQ_MID: String = "EQ_MID"
const val PREF_EQ_HIGH: String = "EQ_HIGH"
const val PREF_EQUALIZER: String = "EQUALIZER_SETTINGS"
const val PREF_EQ_BAND_1: String = "EQ_BAND_1"
const val PREF_EQ_BAND_2: String = "EQ_BAND_2"
const val PREF_EQ_BAND_3: String = "EQ_BAND_3"
const val PREF_EQ_BAND_4: String = "EQ_BAND_4"
const val PREF_EQ_BAND_5: String = "EQ_BAND_5"
const val PREF_EQ_BAND_6: String = "EQ_BAND_6"
const val PREF_EQ_BAND_7: String = "EQ_BAND_7"
const val PREF_EQ_BAND_8: String = "EQ_BAND_8"
const val PREF_PRESET_SELECTED: String = "PRESET_SELECTED"
const val PREF_PRESET_EQ_BAND_0: String = "PRESET_EQ_BAND_0"
const val PREF_PRESET_EQ_BAND_1: String = "PRESET_EQ_BAND_1"
const val PREF_PRESET_EQ_BAND_2: String = "PRESET_EQ_BAND_2"
const val PREF_PRESET_EQ_BAND_3: String = "PRESET_EQ_BAND_3"
const val PREF_PRESET_EQ_BAND_4: String = "PRESET_EQ_BAND_4"
const val PREF_PRESET_EQ_BAND_5: String = "PRESET_EQ_BAND_5"
const val PREF_PRESET_EQ_BAND_6: String = "PRESET_EQ_BAND_6"
const val PREF_PRESET_EQ_BAND_7: String = "PRESET_EQ_BAND_7"
const val PREF_PRESET_EQ_BAND_8: String = "PRESET_EQ_BAND_8"
const val PREF_PRESET_EQ_BAND_9: String = "PRESET_EQ_BAND_9"
const val PREF_PRESET_BASS_BOOST: String = "PRESET_BASS_BOOST"
const val PREF_PRESET_REVERB: String = "PRESET_REVERB"
const val PREF_PRESET_DRC: String = "PRESET_DRC"
const val PREF_PRESET_STEREO_WIDTH: String = "PRESET_STEREO_WIDTH"
// default const values
const val DEFAULT_SIZE_OF_METADATA_HISTORY: Int = 25
@@ -286,20 +286,61 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
/* Applies audio effects based on preferences */
private fun applyAudioEffects() {
nativeAudioProcessor.enableBassBoost(PreferencesHelper.loadBassBoost())
nativeAudioProcessor.setReverb(PreferencesHelper.loadReverb())
nativeAudioProcessor.enableDrc(PreferencesHelper.loadDrcEnabled())
nativeAudioProcessor.setEq(0, PreferencesHelper.loadEqLow())
nativeAudioProcessor.setEq(1, PreferencesHelper.loadEqMid())
nativeAudioProcessor.setEq(2, PreferencesHelper.loadEqHigh())
val selectedPreset = PreferencesHelper.loadSelectedPreset()
if (selectedPreset.isNotEmpty()) {
applyPreset(selectedPreset)
} else {
// Apply manual settings
nativeAudioProcessor.enableBassBoost(PreferencesHelper.loadBassBoost())
nativeAudioProcessor.setReverb(PreferencesHelper.loadReverb())
nativeAudioProcessor.enableDrc(PreferencesHelper.loadDrcEnabled())
nativeAudioProcessor.setWidth(1f)
// Apply all 10 EQ bands
val eqGains = FloatArray(10)
for (i in 0 until 10) {
eqGains[i] = PreferencesHelper.loadEqBand(i).toFloat()
}
nativeAudioProcessor.setEqAll(eqGains)
}
}
/* Applies a saved preset */
private fun applyPreset(presetName: String) {
when (presetName) {
getString(R.string.pref_preset_rock) -> nativeAudioProcessor.setPresetRock()
getString(R.string.pref_preset_pop) -> nativeAudioProcessor.setPresetPop()
getString(R.string.pref_preset_jazz) -> nativeAudioProcessor.setPresetJazz()
getString(R.string.pref_preset_flat) -> nativeAudioProcessor.setPresetFlat()
else -> {
// Custom preset - load from preferences
nativeAudioProcessor.enableDrc(PreferencesHelper.loadPresetDrc())
nativeAudioProcessor.setReverb(PreferencesHelper.loadPresetReverb())
nativeAudioProcessor.setWidth(PreferencesHelper.loadPresetStereoWidth())
nativeAudioProcessor.enableBassBoost(PreferencesHelper.loadPresetBassBoost())
val eqGains = FloatArray(10)
for (i in 0 until 10) {
eqGains[i] = PreferencesHelper.loadPresetEqBand(i).toFloat()
}
nativeAudioProcessor.setEqAll(eqGains)
}
}
}
/* Overrides onSharedPreferenceChanged from SharedPreferences.OnSharedPreferenceChangeListener */
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
Keys.PREF_BASS_BOOST, Keys.PREF_REVERB, Keys.PREF_DRC,
Keys.PREF_EQ_LOW, Keys.PREF_EQ_MID, Keys.PREF_EQ_HIGH -> {
Keys.PREF_BASS_BOOST, Keys.PREF_REVERB, Keys.PREF_DRC,
Keys.PREF_EQ_LOW, Keys.PREF_EQ_MID, Keys.PREF_EQ_HIGH,
Keys.PREF_EQ_BAND_1, Keys.PREF_EQ_BAND_2, Keys.PREF_EQ_BAND_3,
Keys.PREF_EQ_BAND_4, Keys.PREF_EQ_BAND_5, Keys.PREF_EQ_BAND_6,
Keys.PREF_EQ_BAND_7, Keys.PREF_EQ_BAND_8,
Keys.PREF_PRESET_SELECTED,
Keys.PREF_PRESET_EQ_BAND_0, Keys.PREF_PRESET_EQ_BAND_1, Keys.PREF_PRESET_EQ_BAND_2,
Keys.PREF_PRESET_EQ_BAND_3, Keys.PREF_PRESET_EQ_BAND_4, Keys.PREF_PRESET_EQ_BAND_5,
Keys.PREF_PRESET_EQ_BAND_6, Keys.PREF_PRESET_EQ_BAND_7, Keys.PREF_PRESET_EQ_BAND_8,
Keys.PREF_PRESET_EQ_BAND_9,
Keys.PREF_PRESET_BASS_BOOST, Keys.PREF_PRESET_REVERB,
Keys.PREF_PRESET_DRC, Keys.PREF_PRESET_STEREO_WIDTH -> {
applyAudioEffects()
}
}
@@ -334,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)
}
@@ -420,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)
}
@@ -17,6 +17,7 @@ import androidx.navigation.fragment.findNavController
import androidx.preference.*
import com.google.android.material.snackbar.Snackbar
import com.michatec.radio.dialogs.ErrorDialog
import com.michatec.radio.dialogs.PresetSelectionDialog
import com.michatec.radio.dialogs.ThemeSelectionDialog
import com.michatec.radio.dialogs.YesNoDialog
import com.michatec.radio.helpers.*
@@ -30,7 +31,7 @@ import java.util.*
/*
* SettingsFragment class
*/
class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListener, ThemeSelectionDialog.ThemeSelectionDialogListener {
class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListener, ThemeSelectionDialog.ThemeSelectionDialogListener, PresetSelectionDialog.PresetSelectionDialogListener {
/* Define log tag */
@@ -52,6 +53,9 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
val context = preferenceManager.context
val screen = preferenceManager.createPreferenceScreen(context)
// Load current preset once
val currentPreset = PreferencesHelper.loadSelectedPreset()
// set up "App Theme" preference
val preferenceThemeSelection = Preference(activity as Context)
preferenceThemeSelection.title = getString(R.string.pref_theme_selection_title)
@@ -190,6 +194,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
preferenceBassBoost.title = getString(R.string.pref_bass_boost_title)
preferenceBassBoost.setIcon(R.drawable.ic_music_note_24dp)
preferenceBassBoost.key = Keys.PREF_BASS_BOOST
preferenceBassBoost.isEnabled = currentPreset.isEmpty()
preferenceBassBoost.summary = getString(R.string.pref_bass_boost_summary)
preferenceBassBoost.setDefaultValue(false)
@@ -198,6 +203,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
preferenceReverb.title = getString(R.string.pref_reverb_title)
preferenceReverb.setIcon(R.drawable.ic_music_note_24dp)
preferenceReverb.key = Keys.PREF_REVERB
preferenceReverb.isEnabled = currentPreset.isEmpty()
preferenceReverb.summary = getString(R.string.pref_reverb_summary)
preferenceReverb.setDefaultValue(false)
@@ -206,19 +212,53 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
preferenceDrc.title = getString(R.string.pref_drc_title)
preferenceDrc.setIcon(R.drawable.ic_music_note_24dp)
preferenceDrc.key = Keys.PREF_DRC
preferenceDrc.isEnabled = currentPreset.isEmpty()
preferenceDrc.summary = getString(R.string.pref_drc_summary)
preferenceDrc.setDefaultValue(true)
// set up "Preset Selection" preference
val preferencePresetSelection = Preference(context)
preferencePresetSelection.title = getString(R.string.pref_preset_selection_title)
preferencePresetSelection.setIcon(R.drawable.ic_music_note_24dp)
preferencePresetSelection.key = Keys.PREF_PRESET_SELECTED
val presetSummary = currentPreset.ifEmpty {
getString(R.string.pref_preset_none)
}
preferencePresetSelection.summary = "${getString(R.string.pref_preset_selection_summary)}: $presetSummary"
preferencePresetSelection.setOnPreferenceClickListener {
PresetSelectionDialog(this).show(activity as Context)
return@setOnPreferenceClickListener true
}
// Initialize EQ control states based on current preset
updateEqControlStates()
// set up "Equalizer" preference entry
val preferenceEqualizer = Preference(context)
preferenceEqualizer.title = getString(R.string.pref_equalizer_title)
preferenceEqualizer.setIcon(R.drawable.ic_music_note_24dp)
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary)
preferenceEqualizer.key = Keys.PREF_EQUALIZER
if (currentPreset.isEmpty()) {
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary)
preferenceEqualizer.isEnabled = true
} else {
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary_off)
preferenceEqualizer.isEnabled = false
}
preferenceEqualizer.setOnPreferenceClickListener {
findNavController().navigate(R.id.action_settings_to_equalizer)
return@setOnPreferenceClickListener true
}
val preferenceVisualizer = Preference(context)
preferenceVisualizer.title = getString(R.string.pref_visualizer_title)
preferenceVisualizer.setIcon(R.drawable.ic_music_note_24dp)
preferenceVisualizer.summary = getString(R.string.pref_visualizer_summary)
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)
@@ -268,7 +308,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)
@@ -295,7 +335,9 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
preferenceCategoryAudioEffects.addPreference(preferenceBassBoost)
preferenceCategoryAudioEffects.addPreference(preferenceReverb)
preferenceCategoryAudioEffects.addPreference(preferenceDrc)
preferenceCategoryAudioEffects.addPreference(preferencePresetSelection)
preferenceCategoryAudioEffects.addPreference(preferenceEqualizer)
preferenceCategoryAudioEffects.addPreference(preferenceVisualizer)
screen.addPreference(preferenceCategoryMaintenance)
preferenceCategoryMaintenance.addPreference(preferenceUpdateStationImages)
@@ -340,6 +382,49 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
}
/* Overrides onPresetSelectionDialog from PresetSelectionDialogListener */
override fun onPresetSelectionDialog(dialogResult: Boolean, selectedPreset: String) {
if (dialogResult) {
// update summary
val presetPreference = findPreference<Preference>(Keys.PREF_PRESET_SELECTED)
val presetSummary = selectedPreset.ifEmpty {
getString(R.string.pref_preset_none)
}
presetPreference?.summary = "${getString(R.string.pref_preset_selection_summary)}: $presetSummary"
// Enable/disable manual EQ controls based on preset selection
updateEqControlStates()
}
}
/* Updates the enabled/disabled state of EQ controls based on preset selection */
private fun updateEqControlStates() {
val currentPreset = PreferencesHelper.loadSelectedPreset()
val isPresetSelected = currentPreset.isNotEmpty()
// 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) {
if (isPresetSelected) {
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary_off)
preferenceEqualizer.isEnabled = false
} else {
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary)
preferenceEqualizer.isEnabled = true
}
}
}
/* Overrides onYesNoDialog from YesNoDialogListener */
override fun onYesNoDialog(
type: Int,
@@ -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, 18) // ~60 FPS
}
}
private fun startPolling() {
handler.removeCallbacks(pollRunnable)
handler.post(pollRunnable)
}
private fun stopPolling() {
handler.removeCallbacks(pollRunnable)
}
}
@@ -0,0 +1,86 @@
package com.michatec.radio.dialogs
import android.content.Context
import android.view.LayoutInflater
import android.widget.RadioButton
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat.getString
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.michatec.radio.R
import com.michatec.radio.helpers.PreferencesHelper
/*
* PresetSelectionDialog class
*/
class PresetSelectionDialog(private var presetSelectionDialogListener: PresetSelectionDialogListener) {
/* Interface used to communicate back to activity */
interface PresetSelectionDialogListener {
fun onPresetSelectionDialog(dialogResult: Boolean, selectedPreset: String)
}
/* Main class variables */
private lateinit var dialog: AlertDialog
/* Construct and show dialog */
fun show(context: Context) {
// prepare dialog builder
val builder = MaterialAlertDialogBuilder(context)
// inflate custom layout
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(R.layout.dialog_preset_selection, null)
// find radio buttons
val radioGroup = view.findViewById<android.widget.RadioGroup>(R.id.preset_radio_group)
val radioNone = view.findViewById<RadioButton>(R.id.radio_preset_none)
val radioRock = view.findViewById<RadioButton>(R.id.radio_preset_rock)
val radioPop = view.findViewById<RadioButton>(R.id.radio_preset_pop)
val radioJazz = view.findViewById<RadioButton>(R.id.radio_preset_jazz)
val radioFlat = view.findViewById<RadioButton>(R.id.radio_preset_flat)
// set current selection
val currentPreset = PreferencesHelper.loadSelectedPreset()
when (currentPreset) {
"" -> radioNone.isChecked = true
getString(context, R.string.pref_preset_rock) -> radioRock.isChecked = true
getString(context, R.string.pref_preset_pop) -> radioPop.isChecked = true
getString(context, R.string.pref_preset_jazz) -> radioJazz.isChecked = true
getString(context, R.string.pref_preset_flat) -> radioFlat.isChecked = true
else -> radioNone.isChecked = true
}
// set up radio group listener
radioGroup.setOnCheckedChangeListener { _, checkedId ->
val selectedPreset = when (checkedId) {
R.id.radio_preset_none -> ""
R.id.radio_preset_rock -> getString(context, R.string.pref_preset_rock)
R.id.radio_preset_pop -> getString(context, R.string.pref_preset_pop)
R.id.radio_preset_jazz -> getString(context, R.string.pref_preset_jazz)
R.id.radio_preset_flat -> getString(context, R.string.pref_preset_flat)
else -> ""
}
// save preset selection to preferences
PreferencesHelper.saveSelectedPreset(selectedPreset)
// notify listener
presetSelectionDialogListener.onPresetSelectionDialog(true, selectedPreset)
// dismiss dialog
dialog.dismiss()
}
// set custom view
builder.setView(view)
// handle outside-click as cancel
builder.setOnCancelListener {
presetSelectionDialogListener.onPresetSelectionDialog(false, "")
}
// display dialog
dialog = builder.create()
dialog.show()
}
}
@@ -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,46 +14,117 @@ import java.nio.ByteOrder
class NativeAudioProcessor : BaseAudioProcessor() {
companion object {
private const val TAG = "NativeAudioProcessor"
init {
System.loadLibrary("radio")
try {
System.loadLibrary("dsp")
} catch (e: Exception) {
Log.e(TAG, "Failed to load dsp library", e)
}
}
}
// JNI Methods
private var directBuffer: ByteBuffer? = null
// ===== JNI =====
private external fun setDrcEnabled(enabled: Boolean)
private external fun setReverbMix(mix: Float)
private external fun setEqBand(band: Int, gainDb: Float)
private external fun setBassBoost(gainDb: Float)
private external fun processAudio(data: ShortArray, size: Int)
private external fun setStereoWidth(width: Float)
private external fun processAudioDirect(buf: ByteBuffer, size: Int)
private external fun getFftData(): FloatArray
// Public API
// ===== API =====
fun enableDrc(enabled: Boolean) = setDrcEnabled(enabled)
fun setReverb(mix: Float) = setReverbMix(mix)
fun setEq(band: Int, gainDb: Float) = setEqBand(band, gainDb)
fun setEqAll(gains: FloatArray) {
gains.forEachIndexed { i, g -> setEq(i, g) }
}
fun enableBassBoost(gainDb: Float) = setBassBoost(gainDb)
fun setWidth(width: Float) = setStereoWidth(width)
fun getVisualizer(): FloatArray {
val raw = getFftData()
val out = FloatArray(raw.size)
for (i in raw.indices) out[i] = kotlin.math.log10(1f + raw[i])
return out
}
// ===== AudioProcessor Overrides =====
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat {
// 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
}
override fun queueInput(inputBuffer: ByteBuffer) {
val remaining = inputBuffer.remaining()
if (remaining == 0) return
val size = inputBuffer.remaining()
if (size == 0) return
val shortArraySize = remaining / 2
val shortArray = ShortArray(shortArraySize)
// 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)
inputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer().get(shortArray)
// Copy processed data back to output
val out = replaceOutputBuffer(size)
out.order(ByteOrder.nativeOrder())
directBuffer!!.position(0)
out.put(directBuffer!!)
out.flip()
processAudio(shortArray, shortArraySize)
}
val outputBuffer = replaceOutputBuffer(remaining)
outputBuffer.asShortBuffer().put(shortArray)
outputBuffer.limit(remaining)
override fun onReset() {
super.onReset()
directBuffer = null
}
inputBuffer.position(inputBuffer.limit())
// ===== Presets =====
fun setPresetRock() {
enableDrc(true)
setReverb(0.10f)
setWidth(1.1f)
setEqAll(floatArrayOf(2f, 1f, 0f, -1f, -1f, 0f, 1f, 2f, 2f, 3f))
enableBassBoost(0.6f)
}
fun setPresetPop() {
enableDrc(true)
setReverb(0.15f)
setWidth(1.05f)
setEqAll(floatArrayOf(0f, 1f, 1f, 1f, 0f, 0f, 1f, 2f, 2f, 1f))
enableBassBoost(0.5f)
}
fun setPresetJazz() {
enableDrc(false)
setReverb(0.15f)
setWidth(1.0f)
setEqAll(floatArrayOf(0f, 0f, 1f, 1f, 0f, 0f, 1f, 1f, 0f, 0f))
enableBassBoost(0.2f)
}
fun setPresetFlat() {
enableDrc(false)
setReverb(0f)
setWidth(1f)
setEqAll(FloatArray(10))
enableBassBoost(0f)
}
}
@@ -257,13 +257,13 @@ object PreferencesHelper {
/* Loads Bass Boost gain */
fun loadBassBoost(): Float {
return if (sharedPreferences.getBoolean(Keys.PREF_BASS_BOOST, false)) 5.0f else 0.0f
return if (sharedPreferences.getBoolean(Keys.PREF_BASS_BOOST, false)) 0.4f else 0.0f
}
/* Loads Reverb mix */
fun loadReverb(): Float {
return if (sharedPreferences.getBoolean(Keys.PREF_REVERB, false)) 0.3f else 0.0f
return if (sharedPreferences.getBoolean(Keys.PREF_REVERB, false)) 0.2f else 0.0f
}
@@ -272,10 +272,69 @@ object PreferencesHelper {
return sharedPreferences.getBoolean(Keys.PREF_DRC, false)
}
/* Loads EQ gains */
fun loadEqLow(): Float = sharedPreferences.getInt(Keys.PREF_EQ_LOW, 0).toFloat()
fun loadEqMid(): Float = sharedPreferences.getInt(Keys.PREF_EQ_MID, 0).toFloat()
fun loadEqHigh(): Float = sharedPreferences.getInt(Keys.PREF_EQ_HIGH, 0).toFloat()
/* Loads all EQ bands (10 bands for full range) */
fun loadEqBand(band: Int): Int {
return when (band) {
0 -> sharedPreferences.getInt(Keys.PREF_EQ_LOW, 0)
1 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_1, 0)
2 -> sharedPreferences.getInt(Keys.PREF_EQ_MID, 0)
3 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_3, 0)
4 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_4, 0)
5 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_5, 0)
6 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_6, 0)
7 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_7, 0)
8 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_8, 0)
9 -> sharedPreferences.getInt(Keys.PREF_EQ_HIGH, 0)
else -> 0
}
}
/* Loads selected preset name */
fun loadSelectedPreset(): String {
return sharedPreferences.getString(Keys.PREF_PRESET_SELECTED, "") ?: ""
}
/* Saves selected preset name */
fun saveSelectedPreset(preset: String) {
sharedPreferences.edit { putString(Keys.PREF_PRESET_SELECTED, preset) }
}
/* Loads preset EQ band values */
fun loadPresetEqBand(band: Int): Int {
return when (band) {
0 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_0, 0)
1 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_1, 0)
2 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_2, 0)
3 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_3, 0)
4 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_4, 0)
5 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_5, 0)
6 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_6, 0)
7 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_7, 0)
8 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_8, 0)
9 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_9, 0)
else -> 0
}
}
/* Loads preset Bass Boost */
fun loadPresetBassBoost(): Float {
return sharedPreferences.getFloat(Keys.PREF_PRESET_BASS_BOOST, 0f)
}
/* Loads preset Reverb */
fun loadPresetReverb(): Float {
return sharedPreferences.getFloat(Keys.PREF_PRESET_REVERB, 0f)
}
/* Loads preset DRC */
fun loadPresetDrc(): Boolean {
return sharedPreferences.getBoolean(Keys.PREF_PRESET_DRC, false)
}
/* Loads preset Stereo Width */
fun loadPresetStereoWidth(): Float {
return sharedPreferences.getFloat(Keys.PREF_PRESET_STEREO_WIDTH, 1f)
}
/* Resets Equalizer settings to default */
fun resetEqualizer() {
@@ -0,0 +1,75 @@
<?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="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/dialog_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_selection_title"
android:textSize="24sp"
android:textStyle="bold"
android:paddingBottom="16dp" />
<RadioGroup
android:id="@+id/preset_radio_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/radio_preset_none"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_none"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
<RadioButton
android:id="@+id/radio_preset_rock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_rock"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
<RadioButton
android:id="@+id/radio_preset_pop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_pop"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
<RadioButton
android:id="@+id/radio_preset_jazz"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_jazz"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
<RadioButton
android:id="@+id/radio_preset_flat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_flat"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
</RadioGroup>
</LinearLayout>
@@ -0,0 +1,14 @@
<?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"
android:focusable="true" />
</LinearLayout>
@@ -0,0 +1,65 @@
<?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="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/dialog_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_selection_title"
android:textSize="18sp"
android:textStyle="bold"
android:paddingBottom="12dp" />
<RadioGroup
android:id="@+id/preset_radio_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/radio_preset_none"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_none"
android:textSize="16sp"
android:padding="8dp"/>
<RadioButton
android:id="@+id/radio_preset_rock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_rock"
android:textSize="16sp"
android:padding="8dp"/>
<RadioButton
android:id="@+id/radio_preset_pop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_pop"
android:textSize="16sp"
android:padding="8dp"/>
<RadioButton
android:id="@+id/radio_preset_jazz"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_jazz"
android:textSize="16sp"
android:padding="8dp"/>
<RadioButton
android:id="@+id/radio_preset_flat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_flat"
android:textSize="16sp"
android:padding="8dp"/>
</RadioGroup>
</LinearLayout>
@@ -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>
+20 -3
View File
@@ -55,7 +55,6 @@
<string name="pref_update_collection_summary">Die neueste Version aller Senderinformationen herunterladen.</string>
<string name="dialog_yes_no_message_update_collection">Die neueste Version aller Senderinformationen herunterladen?</string>
<string name="dialog_yes_no_positive_button_update_collection">Aktualisieren</string>
<string name="pref_eq_high_title">Equalizer: Höhen</string>
<string name="pref_advanced_title">Erweitert</string>
<string name="pref_app_version_summary">Version</string>
<string name="pref_app_version_title">App-Version</string>
@@ -125,9 +124,27 @@
<string name="pref_reverb_summary">Reverb-Mix anpassen.</string>
<string name="pref_drc_title">Dynamikkompression</string>
<string name="pref_drc_summary">Den Dynamikbereich für eine gleichbleibende Lautstärke komprimieren.</string>
<string name="pref_eq_low_title">Equalizer: Bass</string>
<string name="pref_eq_mid_title">Equalizer: Mitten</string>
<string name="pref_eq_low_title">Equalizer: 31 Hz</string>
<string name="pref_eq_mid_title">Equalizer: 2 kHz</string>
<string name="pref_eq_high_title">Equalizer: 16 kHz</string>
<string name="pref_eq_band_1_title">Equalizer: 62 Hz</string>
<string name="pref_eq_band_2_title">Equalizer: 125 Hz</string>
<string name="pref_eq_band_3_title">Equalizer: 250 Hz</string>
<string name="pref_eq_band_4_title">Equalizer: 500 Hz</string>
<string name="pref_eq_band_5_title">Equalizer: 1 kHz</string>
<string name="pref_eq_band_6_title">Equalizer: 4 kHz</string>
<string name="pref_eq_band_7_title">Equalizer: 8 kHz</string>
<string name="pref_equalizer_title">Equalizer</string>
<string name="pref_equalizer_summary">Passen Sie die Audio-Frequenzen an.</string>
<string name="pref_equalizer_summary_off">Das Anpassen von den Audio-Frequenzen ist deaktiviert.</string>
<string name="pref_equalizer_reset_title">Equalizer zurücksetzen</string>
<string name="pref_preset_selection_title">Preset auswählen</string>
<string name="pref_preset_selection_summary">Wählen Sie ein Klangprofil</string>
<string name="pref_preset_none">Keines (Manuell)</string>
<string name="pref_preset_rock">Rock</string>
<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>
<string name="pref_visualizer_summary">Sehe die Spektrumanzeige.</string>
</resources>
+20 -3
View File
@@ -70,12 +70,27 @@
<string name="pref_reverb_summary">Adjust reverb mix.</string>
<string name="pref_drc_title">Dynamic Range Compression</string>
<string name="pref_drc_summary">Compress dynamic range for consistent volume.</string>
<string name="pref_eq_low_title">Equalizer: Low</string>
<string name="pref_eq_mid_title">Equalizer: Mid</string>
<string name="pref_eq_high_title">Equalizer: High</string>
<string name="pref_eq_low_title">Equalizer: 31 Hz</string>
<string name="pref_eq_mid_title">Equalizer: 125 Hz</string>
<string name="pref_eq_high_title">Equalizer: 4 kHz</string>
<string name="pref_eq_band_1_title">Equalizer: 62 Hz</string>
<string name="pref_eq_band_2_title">Equalizer: 250 Hz</string>
<string name="pref_eq_band_3_title">Equalizer: 500 Hz</string>
<string name="pref_eq_band_4_title">Equalizer: 1 kHz</string>
<string name="pref_eq_band_5_title">Equalizer: 2 kHz</string>
<string name="pref_eq_band_6_title">Equalizer: 8 kHz</string>
<string name="pref_eq_band_7_title">Equalizer: 16 kHz</string>
<string name="pref_equalizer_title">Equalizer</string>
<string name="pref_equalizer_summary">Adjust audio frequencies</string>
<string name="pref_equalizer_summary_off">Adjust audio frequencies is off.</string>
<string name="pref_equalizer_reset_title">Reset Equalizer</string>
<string name="pref_preset_selection_title">Select Preset</string>
<string name="pref_preset_selection_summary">Choose an audio preset</string>
<string name="pref_preset_none">None (Manual)</string>
<string name="pref_preset_rock">Rock</string>
<string name="pref_preset_pop">Pop</string>
<string name="pref_preset_jazz">Jazz</string>
<string name="pref_preset_flat">Flat</string>
<string name="pref_advanced_title">Advanced</string>
<string name="pref_app_version_summary">Version</string>
<string name="pref_app_version_title">App Version</string>
@@ -158,4 +173,6 @@
<!-- Extras -->
<string name="loading">Loading...</string>
<string name="media_route_menu_title">Cast</string>
<string name="pref_visualizer_title">Spectrum Analyzer</string>
<string name="pref_visualizer_summary">Show the Spectrum Analyzer.</string>
</resources>