App 2.43.3: sound check on the Logs tab
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
This commit is contained in:
@@ -412,6 +412,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
MediaProbe.start(this);
|
||||
refreshMediaProbe();
|
||||
});
|
||||
logPane.findViewById(R.id.sound_probe_button).setOnClickListener(v -> {
|
||||
SoundProbe.start(this);
|
||||
refreshSoundProbe();
|
||||
});
|
||||
|
||||
setUpPager();
|
||||
// Back on the page that was open when the activity was recreated (accent colour change).
|
||||
@@ -2341,9 +2345,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (!status.isEmpty()) text.setText(String.join("\n", status));
|
||||
}
|
||||
|
||||
/** The sound check: its button and its last report. */
|
||||
private void refreshSoundProbe() {
|
||||
MaterialButton button = logPane.findViewById(R.id.sound_probe_button);
|
||||
button.setEnabled(!SoundProbe.running());
|
||||
TextView text = logPane.findViewById(R.id.sound_probe_text);
|
||||
java.util.List<String> status = SoundProbe.status(this);
|
||||
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
|
||||
if (!status.isEmpty()) text.setText(String.join("\n", status));
|
||||
}
|
||||
|
||||
private void refreshCallLog() {
|
||||
refreshSignalRecorder();
|
||||
refreshMediaProbe();
|
||||
refreshSoundProbe();
|
||||
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
||||
// Update steps first: after a failed install, they are what the driver came here for.
|
||||
java.util.List<String> updates = UpdateLog.lines(this);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.media.AudioManager;
|
||||
import android.media.audiofx.Visualizer;
|
||||
import android.media.session.MediaController;
|
||||
import android.media.session.MediaSessionManager;
|
||||
import android.media.session.PlaybackState;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The sound check on the Logs tab: whether Android's audio analyser ({@link Visualizer} on the
|
||||
* output mix, session 0) can hear what the head unit plays, before the Halloween pumpkin's light
|
||||
* follows the music. Only one tap starts it, and it listens for {@value #SECONDS} seconds and lets
|
||||
* go. It records levels only, never the sound, and uses no microphone. The report goes where a
|
||||
* signal recording goes ({@link SignalRecorder#saveCopies}): the app, a USB stick, the server.
|
||||
*/
|
||||
final class SoundProbe {
|
||||
|
||||
static final String FILE = "sound_probe.txt";
|
||||
private static final int SECONDS = 10, READS_PER_SECOND = 10;
|
||||
|
||||
private static volatile boolean running;
|
||||
private static long startedMs;
|
||||
private static List<String> report = new ArrayList<>();
|
||||
private static String result;
|
||||
|
||||
private SoundProbe() {}
|
||||
|
||||
static synchronized boolean running() {
|
||||
return running;
|
||||
}
|
||||
|
||||
static synchronized void start(Context context) {
|
||||
if (running) return;
|
||||
Context app = context.getApplicationContext();
|
||||
running = true;
|
||||
startedMs = System.currentTimeMillis();
|
||||
result = null;
|
||||
report = new ArrayList<>();
|
||||
new Thread(() -> probe(app), "sound-probe").start();
|
||||
}
|
||||
|
||||
private static void probe(Context app) {
|
||||
List<String> out = new ArrayList<>();
|
||||
out.add("Sound probe, " + SignalRecording.time(startedMs) + ", app " + BuildConfig.VERSION_NAME);
|
||||
String saved;
|
||||
try {
|
||||
permission(app, Manifest.permission.RECORD_AUDIO, out);
|
||||
permission(app, Manifest.permission.MODIFY_AUDIO_SETTINGS, out);
|
||||
boolean plays = sessions(app, out);
|
||||
plays |= musicActive(app, out);
|
||||
SoundLevels.Summary all = listen(out);
|
||||
plays |= musicActive(app, out);
|
||||
out.add("");
|
||||
out.add(SoundLevels.words(all.verdict(plays)));
|
||||
} catch (Throwable t) {
|
||||
out.add("probe stopped: " + t.getClass().getSimpleName());
|
||||
} finally {
|
||||
String name = "sound-probe-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||
.format(new Date(startedMs)) + ".txt";
|
||||
saved = SignalRecorder.saveCopies(app, FILE, name, out);
|
||||
synchronized (SoundProbe.class) {
|
||||
report = out;
|
||||
result = saved;
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void permission(Context app, String name, List<String> out) {
|
||||
boolean granted = app.checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
|
||||
out.add(name.substring(name.lastIndexOf('.') + 1) + ": " + (granted ? "granted" : "not granted"));
|
||||
}
|
||||
|
||||
private static boolean musicActive(Context app, List<String> out) {
|
||||
try {
|
||||
AudioManager am = (AudioManager) app.getSystemService(Context.AUDIO_SERVICE);
|
||||
boolean active = am != null && am.isMusicActive();
|
||||
out.add("music active: " + active);
|
||||
return active;
|
||||
} catch (RuntimeException e) {
|
||||
out.add("music active: unreadable (" + e.getClass().getSimpleName() + ")");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Which source is playing, for reading the report later; no song. True when one is. */
|
||||
private static boolean sessions(Context app, List<String> out) {
|
||||
boolean playing = false;
|
||||
try {
|
||||
MediaSessionManager msm = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE);
|
||||
List<MediaController> controllers = msm == null ? null : msm.getActiveSessions(null);
|
||||
if (controllers == null || controllers.isEmpty()) out.add("sessions: none");
|
||||
else for (MediaController c : controllers) {
|
||||
PlaybackState s = c.getPlaybackState();
|
||||
out.add("session: " + MediaProbeText.clean(c.getPackageName()) + ", "
|
||||
+ (s == null ? "no state" : MediaProbeText.playbackState(s.getState())));
|
||||
if (s != null && s.getState() == PlaybackState.STATE_PLAYING) playing = true;
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
out.add("sessions: not readable (" + t.getClass().getSimpleName() + ")");
|
||||
}
|
||||
return playing;
|
||||
}
|
||||
|
||||
/** Listens for the whole time, one report line a second; always lets the analyser go. */
|
||||
private static SoundLevels.Summary listen(List<String> out) throws InterruptedException {
|
||||
SoundLevels.Summary all = new SoundLevels.Summary();
|
||||
Visualizer analyser = null;
|
||||
try {
|
||||
analyser = new Visualizer(0);
|
||||
int[] sizes = Visualizer.getCaptureSizeRange();
|
||||
int code = analyser.setCaptureSize(sizes[1]);
|
||||
analyser.setScalingMode(Visualizer.SCALING_MODE_NORMALIZED);
|
||||
analyser.setMeasurementMode(Visualizer.MEASUREMENT_MODE_PEAK_RMS);
|
||||
int enabled = analyser.setEnabled(true);
|
||||
int size = analyser.getCaptureSize(), rateHz = analyser.getSamplingRate() / 1000;
|
||||
out.add("analyser: capture " + size + " at " + rateHz + " Hz, set size " + code + ", enable " + enabled);
|
||||
if (enabled != Visualizer.SUCCESS) return all;
|
||||
byte[] wave = new byte[size], fft = new byte[size];
|
||||
Visualizer.MeasurementPeakRms measure = new Visualizer.MeasurementPeakRms();
|
||||
for (int second = 0; second < SECONDS; second++) {
|
||||
SoundLevels.Summary now = new SoundLevels.Summary();
|
||||
for (int read = 0; read < READS_PER_SECOND; read++) {
|
||||
Thread.sleep(1000 / READS_PER_SECOND);
|
||||
Float peak = analyser.getWaveForm(wave) == Visualizer.SUCCESS ? SoundLevels.peak(wave) : null;
|
||||
boolean spectrum = analyser.getFft(fft) == Visualizer.SUCCESS;
|
||||
Float bass = spectrum ? SoundLevels.band(fft, rateHz, 20f, 250f) : null;
|
||||
Float mid = spectrum ? SoundLevels.band(fft, rateHz, 250f, 2000f) : null;
|
||||
Float treble = spectrum ? SoundLevels.band(fft, rateHz, 2000f, 16000f) : null;
|
||||
Integer rms = analyser.getMeasurementPeakRms(measure) == Visualizer.SUCCESS ? measure.mRms : null;
|
||||
now.add(peak, bass, mid, treble, rms);
|
||||
all.add(peak, bass, mid, treble, rms);
|
||||
}
|
||||
out.add(MediaProbeText.duration(second * 1000L) + " " + now.line());
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
out.add("analyser not available: " + e.getClass().getSimpleName() + " " + MediaProbeText.clean(e.getMessage()));
|
||||
} finally {
|
||||
if (analyser != null) {
|
||||
try {
|
||||
analyser.setEnabled(false);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Released next either way.
|
||||
}
|
||||
try {
|
||||
analyser.release();
|
||||
} catch (RuntimeException e) {
|
||||
out.add("analyser not released: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** What the Logs tab shows: the state, then the report. */
|
||||
static synchronized List<String> status(Context context) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
if (startedMs == 0) return lines;
|
||||
if (running) {
|
||||
lines.add(context.getString(R.string.sound_probe_running));
|
||||
return lines;
|
||||
}
|
||||
lines.add(context.getString(R.string.media_probe_done, SignalRecording.time(startedMs),
|
||||
result == null ? "…" : result));
|
||||
String sent = LogOutbox.status(context);
|
||||
if (sent != null) lines.add(sent);
|
||||
lines.addAll(report.subList(Math.min(1, report.size()), report.size()));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user