One tap listens for 10 seconds with Android's audio analyser on the output mix and reports the levels, to learn whether it hears Bluetooth, CarPlay and radio before the Halloween pumpkin's light follows the music. Levels only, no sound kept, no microphone. Heard a test tone on the emulator. Not tried on the car. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
109 lines
4.4 KiB
Java
109 lines
4.4 KiB
Java
package com.evsuite.abrp;
|
||
|
||
import java.util.Locale;
|
||
|
||
/**
|
||
* What {@link SoundProbe} makes of Android's audio analyser: how loud a captured waveform is, how
|
||
* strong a band of its spectrum, and whether a run of those looks like music the analyser can
|
||
* hear. A failed read is null, never a silent 0. Android-free and unit-tested.
|
||
*/
|
||
final class SoundLevels {
|
||
|
||
/** Below this the peak counts as silence (the 8-bit waveform's noise is about 1/128). */
|
||
static final float SILENT = 0.02f;
|
||
|
||
private SoundLevels() {}
|
||
|
||
/** The waveform's peak from 0 to 1. Its samples are unsigned 8-bit, with silence at 128. */
|
||
static Float peak(byte[] wave) {
|
||
if (wave == null || wave.length == 0) return null;
|
||
int max = 0;
|
||
for (byte b : wave) max = Math.max(max, Math.abs((b & 0xFF) - 128));
|
||
return Math.min(1f, max / 128f);
|
||
}
|
||
|
||
/**
|
||
* The mean strength from 0 to 1 of the bins between {@code fromHz} and {@code toHz}. The
|
||
* analyser's FFT holds the DC and Nyquist values first, then each bin's real and imaginary
|
||
* part, signed; a bin {@code k} is {@code k × rate / length} Hz.
|
||
*/
|
||
static Float band(byte[] fft, int rateHz, float fromHz, float toHz) {
|
||
if (fft == null || fft.length < 4 || rateHz <= 0) return null;
|
||
float sum = 0f;
|
||
int bins = 0;
|
||
for (int k = 1; k < fft.length / 2; k++) {
|
||
float hz = k * (float) rateHz / fft.length;
|
||
if (hz < fromHz || hz >= toHz) continue;
|
||
sum += (float) Math.hypot(fft[2 * k], fft[2 * k + 1]) / 128f;
|
||
bins++;
|
||
}
|
||
return bins == 0 ? null : Math.min(1f, sum / bins);
|
||
}
|
||
|
||
enum Verdict {
|
||
/** The analyser gave no readings at all. */
|
||
NO_READING,
|
||
/** Nothing was playing and the analyser heard nothing: says nothing about the source. */
|
||
NOTHING_PLAYING,
|
||
/** Something played and the analyser heard silence: it cannot hear this source. */
|
||
DEAF,
|
||
/** The level stays where it is: not music, or not real data. */
|
||
STEADY,
|
||
/** The level moves: the pumpkin could follow it. */
|
||
HEARS
|
||
}
|
||
|
||
/** Readings gathered over a time. */
|
||
static final class Summary {
|
||
private int samples, readings, bandReadings;
|
||
private float peakMin = Float.MAX_VALUE, peakMax, bass, mid, treble;
|
||
private Integer rms;
|
||
|
||
/** One round of reads, each null when it failed. */
|
||
void add(Float peak, Float bassBand, Float midBand, Float trebleBand, Integer rmsMilliBel) {
|
||
samples++;
|
||
if (peak != null) {
|
||
readings++;
|
||
peakMin = Math.min(peakMin, peak);
|
||
peakMax = Math.max(peakMax, peak);
|
||
}
|
||
if (bassBand != null && midBand != null && trebleBand != null) {
|
||
bandReadings++;
|
||
bass += bassBand;
|
||
mid += midBand;
|
||
treble += trebleBand;
|
||
}
|
||
if (rmsMilliBel != null) rms = rmsMilliBel;
|
||
}
|
||
|
||
Verdict verdict(boolean somethingPlays) {
|
||
if (readings == 0) return Verdict.NO_READING;
|
||
if (peakMax < SILENT) return somethingPlays ? Verdict.DEAF : Verdict.NOTHING_PLAYING;
|
||
return peakMax - peakMin < SILENT ? Verdict.STEADY : Verdict.HEARS;
|
||
}
|
||
|
||
/** One line of the report; what could not be read is "—". */
|
||
String line() {
|
||
return "peak " + (readings == 0 ? "—" : String.format(Locale.US, "%.2f–%.2f", peakMin, peakMax))
|
||
+ ", bass " + mean(bass) + ", mid " + mean(mid) + ", treble " + mean(treble)
|
||
+ ", rms " + (rms == null ? "—" : rms + " mB")
|
||
+ " (" + readings + "/" + samples + ")";
|
||
}
|
||
|
||
private String mean(float sum) {
|
||
return bandReadings == 0 ? "—" : String.format(Locale.US, "%.3f", sum / bandReadings);
|
||
}
|
||
}
|
||
|
||
/** The report's last line. */
|
||
static String words(Verdict verdict) {
|
||
switch (verdict) {
|
||
case HEARS: return "Result: the analyser hears it, and the level moves.";
|
||
case STEADY: return "Result: the level does not move, so this is not usable music.";
|
||
case DEAF: return "Result: something plays, but the analyser hears silence.";
|
||
case NOTHING_PLAYING: return "Result: nothing was playing, so this says nothing. Play something and check again.";
|
||
default: return "Result: the analyser gave no readings.";
|
||
}
|
||
}
|
||
}
|