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
180 lines
8.1 KiB
Java
180 lines
8.1 KiB
Java
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;
|
|
}
|
|
}
|