From f84e18ef0e20279cb489a5e7b2ea8b9f9e122b18 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 31 Oct 2026 13:17:41 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- .claude/skills/car-logs/SKILL.md | 10 +- .gitignore | 1 + AGENTS.md | 2 +- SPECS.md | 1 + app/build.gradle | 4 +- .../java/com/evsuite/abrp/MainActivity.java | 15 ++ .../java/com/evsuite/abrp/SoundLevels.java | 108 +++++++++++ .../java/com/evsuite/abrp/SoundProbe.java | 179 ++++++++++++++++++ app/src/main/res/layout/pane_log.xml | 7 + app/src/main/res/values/strings.xml | 4 + .../com/evsuite/abrp/SoundLevelsTest.java | 78 ++++++++ app/src/unstable/AndroidManifest.xml | 5 + 12 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/SoundLevels.java create mode 100644 app/src/main/java/com/evsuite/abrp/SoundProbe.java create mode 100644 app/src/test/java/com/evsuite/abrp/SoundLevelsTest.java diff --git a/.claude/skills/car-logs/SKILL.md b/.claude/skills/car-logs/SKILL.md index e30f9ad..fd4a50d 100644 --- a/.claude/skills/car-logs/SKILL.md +++ b/.claude/skills/car-logs/SKILL.md @@ -71,9 +71,17 @@ services by name (2.42.1 on; 2.42.0 also connected to them, which stopped the mu session is `com.allgo.remoteui.mediabrowserservice`, Bluetooth's `com.android.bluetooth`. Ask what was playing when the check ran. +## Sound checks + +`fetch-logs` also brings `sound-probe-YYYYMMDD-HHMMSS.txt` (Logs tab, "Check sound", +`SoundProbe`, 2.43.3 on): permissions, which session plays, then one line a second of the audio +analyser's peak, bass, mid, treble and rms, and a result line. Levels only, no sound. The peak has +to move for music to count as heard. Ask what was playing when the check ran, and whether the +music kept playing and any prompt came up. + ## Rules - Recordings and checks are the car's data: `logs/`, `/signal-recording-*.txt` and - `/media-probe-*.txt` are gitignored. Never + `/media-probe-*.txt`, `/sound-probe-*.txt` are gitignored. Never commit, publish or upload them anywhere. - Findings that change what the project knows go into a `SPECS.md` done-log entry, as usual. diff --git a/.gitignore b/.gitignore index 9174fcd..d279ef5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ mise.local.toml /logs/ /signal-recording-*.txt /media-probe-*.txt +/sound-probe-*.txt diff --git a/AGENTS.md b/AGENTS.md index 9ae8002..87501d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), | Live data | on/off, ABRP API key and user token, upload frequency | | Server | URL, pairing by QR, ingest token, installation ID (read-only), config import | | Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About | -| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app, a USB stick and the server, through `LogOutbox`, which sends it again later when the car is offline; how to find where the car publishes something), music-sources check (`MediaProbe`: audio players and the active media sessions, CarPlay's and Bluetooth's among them, with their song, and the media browser services by name — never connected to: binding them stopped the music in 2.42.0; saved and sent like a recording), update log (every install step, with the platform's words), upload log | +| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app, a USB stick and the server, through `LogOutbox`, which sends it again later when the car is offline; how to find where the car publishes something), music-sources check (`MediaProbe`: audio players and the active media sessions, CarPlay's and Bluetooth's among them, with their song, and the media browser services by name — never connected to: binding them stopped the music in 2.42.0; saved and sent like a recording), sound check (`SoundProbe`, `SoundLevels`: on a tap, 10 seconds of levels from Android's audio analyser, to learn whether it hears what plays; saved and sent like a recording), update log (every install step, with the platform's words), upload log | Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, 15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server diff --git a/SPECS.md b/SPECS.md index c9d59a2..2860e01 100644 --- a/SPECS.md +++ b/SPECS.md @@ -140,6 +140,7 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-31 — App 2.43.3: **sound check** on the Logs tab (`SoundProbe`, `SoundLevels`), asked for before the Halloween pumpkin's light follows the music (owner: "make the light in the pumpkin react to the equalizer/sound of the music"). One tap: 10 seconds of levels from `Visualizer` on session 0 (peak, bass, mid, treble, rms), saved and sent like a recording; no sound kept, no microphone. New in the car build's manifest: RECORD_AUDIO and MODIFY_AUDIO_SETTINGS. Grounds for trying: Bluetooth played with `isMusicActive` true (28 October check), `libCarPlayPlatformAudio.so` uses AudioTrack and no direct PCM. Whether the analyser hears anything on the car is what this is to find out. Tested: unit tests, lint, emulator (silence says nothing was playing; a pulsing 110 Hz + 1 kHz test tone from another app is heard, peak 0.27–0.91, bass strongest, the tone kept playing, no prompt, no crash). Not tried on the car. - 2026-10-31 — App 2.43.2 published (`car-2.43.2`). - 2026-10-31 — App 2.43.2: Halloween's teeth are triangles hanging from the grin's upper lip, each in the colour of the rib behind it (the right one was the darker orange on the lighter rib; owner: "the color on the right side is off"). Two screenshots redone. Tested: unit tests, lint, emulator with sample values (drawing in panel and full screen, regenerating in full screen) and with no data. Not tried on the car. - 2026-10-31 — App 2.43.1 published (`car-2.43.1`). diff --git a/app/build.gradle b/app/build.gradle index 66fa525..eb1cc41 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 155 - versionName "2.43.2" + versionCode 156 + versionName "2.43.3" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 8be8ce1..50836e5 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -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 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 entries = AbrpUploadService.log().recent(); // Update steps first: after a failed install, they are what the driver came here for. java.util.List updates = UpdateLog.lines(this); diff --git a/app/src/main/java/com/evsuite/abrp/SoundLevels.java b/app/src/main/java/com/evsuite/abrp/SoundLevels.java new file mode 100644 index 0000000..23b4d02 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/SoundLevels.java @@ -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."; + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/SoundProbe.java b/app/src/main/java/com/evsuite/abrp/SoundProbe.java new file mode 100644 index 0000000..fc05042 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/SoundProbe.java @@ -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 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 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 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 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 out) { + boolean playing = false; + try { + MediaSessionManager msm = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE); + List 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 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 status(Context context) { + List 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; + } +} diff --git a/app/src/main/res/layout/pane_log.xml b/app/src/main/res/layout/pane_log.xml index b9a3d47..4e733a9 100644 --- a/app/src/main/res/layout/pane_log.xml +++ b/app/src/main/res/layout/pane_log.xml @@ -15,6 +15,13 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 39e97d8..15034d9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -233,6 +233,10 @@ Music sources Finds where the head unit tells what is playing: the playback Android reports and the media sessions apps publish (CarPlay, Bluetooth, radio, USB), each with its song, and the head unit\'s media services by name. Play something first and check once for each source. Like a recording, the result goes to your server and to a USB stick. It only reads and connects to no other app: nothing is played, paused or skipped. Check music sources + Sound check + Finds out whether the app can hear the music, for a light that follows it. Park first, play something, then check once each for Bluetooth, CarPlay and radio. It listens for 10 seconds and keeps only the levels, never the sound, and uses no microphone. Like a recording, the result goes to your server and to a USB stick. + Check sound + Listening… (10 seconds) Checking… (up to a minute) Check from %1$s, %2$s: Sent to the server. diff --git a/app/src/test/java/com/evsuite/abrp/SoundLevelsTest.java b/app/src/test/java/com/evsuite/abrp/SoundLevelsTest.java new file mode 100644 index 0000000..eb1989b --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/SoundLevelsTest.java @@ -0,0 +1,78 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class SoundLevelsTest { + + private static byte[] wave(int... unsigned) { + byte[] out = new byte[unsigned.length]; + for (int i = 0; i < out.length; i++) out[i] = (byte) unsigned[i]; + return out; + } + + @Test + public void silenceIsTheMiddleOfTheWaveform() { + assertEquals(0f, SoundLevels.peak(wave(128, 128, 128)), 0f); + assertEquals(0.5f, SoundLevels.peak(wave(128, 192, 128)), 0f); + assertEquals(0.5f, SoundLevels.peak(wave(128, 64, 128)), 0f); + assertEquals(1f, SoundLevels.peak(wave(0, 255)), 0f); + } + + @Test + public void aFailedReadIsNotSilence() { + assertNull(SoundLevels.peak(null)); + assertNull(SoundLevels.peak(new byte[0])); + assertNull(SoundLevels.band(null, 48000, 0f, 250f)); + assertNull(SoundLevels.band(new byte[8], 0, 0f, 250f)); + } + + @Test + public void aBandAveragesItsOwnBins() { + // Eight values: DC and Nyquist, then bins 1 to 3, here 1000, 2000 and 3000 Hz. + byte[] fft = { 99, 99, 64, 0, 0, 32, 3, 4 }; + assertEquals(0.5f, SoundLevels.band(fft, 8000, 500f, 1500f), 1e-6f); + assertEquals(0.25f, SoundLevels.band(fft, 8000, 2000f, 2500f), 1e-6f); + assertEquals(5f / 128f, SoundLevels.band(fft, 8000, 3000f, 4000f), 1e-6f); + assertEquals((0.5f + 0.25f) / 2f, SoundLevels.band(fft, 8000, 500f, 2500f), 1e-6f); + assertNull(SoundLevels.band(fft, 8000, 5000f, 6000f)); + } + + @Test + public void theVerdictTellsDeafFromNothingPlaying() { + SoundLevels.Summary none = new SoundLevels.Summary(); + none.add(null, null, null, null, null); + assertEquals(SoundLevels.Verdict.NO_READING, none.verdict(true)); + + SoundLevels.Summary silent = new SoundLevels.Summary(); + silent.add(0f, 0f, 0f, 0f, -9600); + silent.add(0.01f, 0f, 0f, 0f, -9600); + assertEquals(SoundLevels.Verdict.DEAF, silent.verdict(true)); + assertEquals(SoundLevels.Verdict.NOTHING_PLAYING, silent.verdict(false)); + } + + @Test + public void onlyAMovingLevelIsHeard() { + SoundLevels.Summary steady = new SoundLevels.Summary(); + steady.add(0.4f, 0.1f, 0.1f, 0.1f, -2000); + steady.add(0.41f, 0.1f, 0.1f, 0.1f, -2000); + assertEquals(SoundLevels.Verdict.STEADY, steady.verdict(true)); + + SoundLevels.Summary music = new SoundLevels.Summary(); + music.add(0.1f, 0.2f, 0.1f, 0f, -3000); + music.add(0.6f, 0.4f, 0.3f, 0.1f, -1500); + assertEquals(SoundLevels.Verdict.HEARS, music.verdict(true)); + } + + @Test + public void theLineAveragesAndMarksWhatFailed() { + SoundLevels.Summary s = new SoundLevels.Summary(); + s.add(0.1f, 0.2f, 0.1f, 0f, -3000); + s.add(null, null, null, null, null); + s.add(0.6f, 0.4f, 0.3f, 0.1f, -1500); + assertEquals("peak 0.10–0.60, bass 0.300, mid 0.200, treble 0.050, rms -1500 mB (2/3)", s.line()); + assertEquals("peak —, bass —, mid —, treble —, rms — (0/0)", new SoundLevels.Summary().line()); + } +} diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml index 8a341f9..3d4bcd6 100644 --- a/app/src/unstable/AndroidManifest.xml +++ b/app/src/unstable/AndroidManifest.xml @@ -18,6 +18,11 @@ android:name="android.permission.MEDIA_CONTENT_CONTROL" tools:ignore="ProtectedPermissions" /> + + + +