Compare commits
9
Commits
car-2.43.1
...
car-2.43.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd38c37ebe | ||
|
|
ea9763e1de | ||
|
|
97e37c8a3c | ||
|
|
744d52f322 | ||
|
|
0fe7897340 | ||
|
|
f84e18ef0e | ||
|
|
024e40cd01 | ||
|
|
ee7cf85255 | ||
|
|
1686b791bc |
@@ -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
|
session is `com.allgo.remoteui.mediabrowserservice`, Bluetooth's `com.android.bluetooth`. Ask what was playing
|
||||||
when the check ran.
|
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
|
## Rules
|
||||||
|
|
||||||
- Recordings and checks are the car's data: `logs/`, `/signal-recording-*.txt` and
|
- 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.
|
commit, publish or upload them anywhere.
|
||||||
- Findings that change what the project knows go into a `SPECS.md` done-log entry, as usual.
|
- Findings that change what the project knows go into a `SPECS.md` done-log entry, as usual.
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ mise.local.toml
|
|||||||
/logs/
|
/logs/
|
||||||
/signal-recording-*.txt
|
/signal-recording-*.txt
|
||||||
/media-probe-*.txt
|
/media-probe-*.txt
|
||||||
|
/sound-probe-*.txt
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
|
|||||||
|
|
||||||
| Tab | Contents |
|
| Tab | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`), Día de Muertos (`MuertosView`, an ofrenda: papel picado, candles, a calavera in a ring of marigolds), Halloween (`HalloweenView`, a jack-o'-lantern, a potion flask, a tombstone on a graveyard hill), both counting with `Tally`; the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire, Día de Muertos, Halloween; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`), Día de Muertos (`MuertosView`, an ofrenda: papel picado, candles, a calavera in a ring of marigolds), Halloween (`HalloweenView`, a jack-o'-lantern, a potion flask, a tombstone on a graveyard hill), both counting with `Tally`, the pumpkin's light following the music on screen (`MusicTap`, `MusicPulse`: Android's audio analyser, levels only; Bluetooth and CarPlay, not the radio); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire, Día de Muertos, Halloween; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
|
||||||
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
||||||
| Live data | on/off, ABRP API key and user token, upload frequency |
|
| 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 |
|
| 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 |
|
| 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,
|
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
||||||
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ 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
|
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`
|
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).
|
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
||||||
|
- 2026-11-01 — App 2.43.4: **the Halloween pumpkin's light follows the music** (`MusicTap`, `MusicPulse`; owner: "make the light in the pumpkin react to the equalizer/sound of the music"). The bass, 25 times a second off the main thread, drives the glow and brightens the carving on the beat; yellow and green still mean drawing and regenerating, the embers still the power, and no signal leaves the light as it was. It listens only with the app in front, on the Overview, in Halloween. The owner reports the sound check worked with Bluetooth and CarPlay; those two reports never reached the server (why not is unexplained), so their levels are unseen and the pulse's feel is untuned for the car. Tested: unit tests, lint, emulator (a tone already playing when the analyser starts: the carving and glow move with it; no sound: unchanged; the analyser is released on another tab and when the app leaves the front; no crash). Not tried on the car; how it looks there, and its effect on the head unit over a long drive, are unknown.
|
||||||
|
- 2026-10-31 — Sound check on the car (2.43.3), radio: `com.saicmotor.radio` playing, `isMusicActive` false, analyser enabled without error and silent for all 10 seconds (peak 0.00, -9600 mB). The audio analyser does not hear the radio. The owner ran Bluetooth and CarPlay too; those reports have not reached the server yet (the outbox sends oldest first and stops at the first failure, retrying while the service runs).
|
||||||
|
- 2026-10-31 — App 2.43.3 published (`car-2.43.3`).
|
||||||
|
- 2026-10-31 — Music checks from the car: the radio publishes a session while it plays (`com.saicmotor.radio`, 29 October, DAB: TITLE the station, ARTIST the programme text, AUTHOR the band), with `isMusicActive` false, so its sound may not pass through Android's mix and the audio analyser may not hear it. The sound check with the radio playing will tell. CarPlay playing (31 October, 2.43.2) has `isMusicActive` true, like Bluetooth on 28 October. The sound check itself has not run on the car yet: 2.43.3 was not published and those reports came from 2.43.2's music-sources check.
|
||||||
|
- 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`).
|
||||||
- 2026-10-31 — App 2.43.1: Halloween's bats can be seen. They were near-black and small on the darkest part of the sky, and the owner did not spot them; now a moonlit purple-grey with a light edge, a third larger. Two screenshots redone. Tested: unit tests, lint, emulator with sample values (panel and full screen) and with no data (no bats). Not tried on the car.
|
- 2026-10-31 — App 2.43.1: Halloween's bats can be seen. They were near-black and small on the darkest part of the sky, and the owner did not spot them; now a moonlit purple-grey with a light edge, a third larger. Two screenshots redone. Tested: unit tests, lint, emulator with sample values (panel and full screen) and with no data (no bats). Not tried on the car.
|
||||||
- 2026-10-31 — App 2.43.0 published (`car-2.43.0`).
|
- 2026-10-31 — App 2.43.0 published (`car-2.43.0`).
|
||||||
- 2026-10-31 — App 2.43.0: Overview styles **Día de Muertos** (`MuertosView`) and **Halloween**
|
- 2026-10-31 — App 2.43.0: Overview styles **Día de Muertos** (`MuertosView`) and **Halloween**
|
||||||
|
|||||||
@@ -262,7 +262,9 @@ the range ("here lies … km"), and the bats are the range too, one per 50 km; P
|
|||||||
gravestones, the one engaged lit. The moon holds the clock, a sign hanging from a cobweb has trip,
|
gravestones, the one engaged lit. The moon holds the clock, a sign hanging from a cobweb has trip,
|
||||||
usage, climate and odometer, and the hill along the bottom is the last two minutes of power. While
|
usage, climate and odometer, and the hill along the bottom is the last two minutes of power. While
|
||||||
you drive the bats fly, the potion bubbles and the ghost floats (where there is room for it, as on
|
you drive the bats fly, the potion bubbles and the ghost floats (where there is room for it, as on
|
||||||
this screen). The app turns the pumpkin's orange with it.
|
this screen). With Bluetooth or CarPlay music playing, the pumpkin's glow swells and its carving
|
||||||
|
brightens on the beat; the radio cannot be heard, so it burns as usual then. The app turns the
|
||||||
|
pumpkin's orange with it.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 154
|
versionCode 157
|
||||||
versionName "2.43.1"
|
versionName "2.43.4"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// 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.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ import java.util.Locale;
|
|||||||
* tombstone reads the range ("here lies … km"); P R N D are four small gravestones, the one engaged
|
* tombstone reads the range ("here lies … km"); P R N D are four small gravestones, the one engaged
|
||||||
* lit. The bats are the range too, one per 50 km. The hill along the bottom is the last two minutes
|
* lit. The bats are the range too, one per 50 km. The hill along the bottom is the last two minutes
|
||||||
* of power, rising with it, in orange or, where it regenerated, green. Unread values show "—" and
|
* of power, rising with it, in orange or, where it regenerated, green. Unread values show "—" and
|
||||||
* light nothing.
|
* light nothing. With music playing, the pumpkin's glow swells and its carving brightens on the
|
||||||
|
* beat ({@link MusicTap}); colours and the embers keep their meaning, and with no music it burns as
|
||||||
|
* before.
|
||||||
*
|
*
|
||||||
* The night sky is drawn once into a bitmap per size; only the rest goes over it. Bats, bubbles and
|
* The night sky is drawn once into a bitmap per size; only the rest goes over it. Bats, bubbles and
|
||||||
* the ghost move (about 30 frames a second) only while the car moves and the view is on screen.
|
* the ghost move (about 30 frames a second) only while the car moves and the view is on screen;
|
||||||
|
* the light's pulse keeps it drawing while there is one.
|
||||||
*/
|
*/
|
||||||
final class HalloweenView extends ClusterCanvasView {
|
final class HalloweenView extends ClusterCanvasView {
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ final class HalloweenView extends ClusterCanvasView {
|
|||||||
private int skyW, skyH;
|
private int skyW, skyH;
|
||||||
private float time;
|
private float time;
|
||||||
private long lastFrame;
|
private long lastFrame;
|
||||||
|
private MusicTap music;
|
||||||
|
|
||||||
HalloweenView(Context context) {
|
HalloweenView(Context context) {
|
||||||
super(context);
|
super(context);
|
||||||
@@ -63,6 +67,11 @@ final class HalloweenView extends ClusterCanvasView {
|
|||||||
line.setStrokeCap(Paint.Cap.ROUND);
|
line.setStrokeCap(Paint.Cap.ROUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where the pumpkin's light hears the music; without it, the light does not follow any. */
|
||||||
|
void setMusic(MusicTap tap) {
|
||||||
|
music = tap;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDetachedFromWindow() {
|
protected void onDetachedFromWindow() {
|
||||||
super.onDetachedFromWindow();
|
super.onDetachedFromWindow();
|
||||||
@@ -91,12 +100,13 @@ final class HalloweenView extends ClusterCanvasView {
|
|||||||
drawGhost(c, w, u);
|
drawGhost(c, w, u);
|
||||||
drawHill(c, w, h, u);
|
drawHill(c, w, h, u);
|
||||||
drawEmbers(c, w, u);
|
drawEmbers(c, w, u);
|
||||||
drawPumpkin(c, w, u);
|
float pulse = music == null ? 0f : music.pulse();
|
||||||
|
drawPumpkin(c, w, u, pulse);
|
||||||
drawSign(c, u);
|
drawSign(c, u);
|
||||||
drawFlask(c, u);
|
drawFlask(c, u);
|
||||||
drawTombstone(c, w, u);
|
drawTombstone(c, w, u);
|
||||||
drawGears(c, w, u);
|
drawGears(c, w, u);
|
||||||
keepAnimating(moving, 33);
|
keepAnimating(moving || pulse > 0f, 33);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
|
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
|
||||||
@@ -289,16 +299,18 @@ final class HalloweenView extends ClusterCanvasView {
|
|||||||
|
|
||||||
// ---------- The jack-o'-lantern: the speed ----------
|
// ---------- The jack-o'-lantern: the speed ----------
|
||||||
|
|
||||||
private void drawPumpkin(Canvas c, int w, float u) {
|
private void drawPumpkin(Canvas c, int w, float u, float pulse) {
|
||||||
float cx = w / 2f, cy = 348f * u, rx = 200f * u, ry = 158f * u;
|
float cx = w / 2f, cy = 348f * u, rx = 200f * u, ry = 158f * u;
|
||||||
boolean regen = regen();
|
boolean regen = regen();
|
||||||
int burn = regen ? FIRE : CANDLE;
|
// The carving brightens on the beat, gently, and stays yellow or green.
|
||||||
// The light it throws, stronger the more power.
|
int burn = brighter(regen ? FIRE : CANDLE, pulse * 0.35f);
|
||||||
|
// The light it throws, stronger the more power, swelling with the music.
|
||||||
float share = v.powerKw == null ? 0f : Math.min(1f, Math.abs(v.powerKw) / 80f);
|
float share = v.powerKw == null ? 0f : Math.min(1f, Math.abs(v.powerKw) / 80f);
|
||||||
glow.setShader(new RadialGradient(cx, cy, rx * 1.6f,
|
float reach = rx * (1.6f + pulse * 0.2f);
|
||||||
new int[] { AccentColor.withAlpha(regen ? FIRE : ORANGE, 0x30 + (int) (share * 0x50)),
|
glow.setShader(new RadialGradient(cx, cy, reach,
|
||||||
|
new int[] { AccentColor.withAlpha(regen ? FIRE : ORANGE, 0x30 + (int) (share * 0x50) + (int) (pulse * 0x60)),
|
||||||
AccentColor.withAlpha(regen ? FIRE : ORANGE, 0) }, null, Shader.TileMode.CLAMP));
|
AccentColor.withAlpha(regen ? FIRE : ORANGE, 0) }, null, Shader.TileMode.CLAMP));
|
||||||
c.drawCircle(cx, cy, rx * 1.6f, glow);
|
c.drawCircle(cx, cy, reach, glow);
|
||||||
// The stem.
|
// The stem.
|
||||||
path.reset();
|
path.reset();
|
||||||
path.moveTo(cx - 22f * u, cy - ry + 12f * u);
|
path.moveTo(cx - 22f * u, cy - ry + 12f * u);
|
||||||
@@ -339,10 +351,9 @@ final class HalloweenView extends ClusterCanvasView {
|
|||||||
fill.setColor(burn);
|
fill.setColor(burn);
|
||||||
c.drawPath(path, fill);
|
c.drawPath(path, fill);
|
||||||
c.restore();
|
c.restore();
|
||||||
// Two teeth at the grin's corners, clear of the digits.
|
// Two teeth at the grin's corners, clear of the digits, each the colour of the rib it hangs from.
|
||||||
fill.setColor(PUMPKIN);
|
tooth(c, cx, -120f * u, gw, gt, 26f * u, 17f * u, gt + 40f * u, PUMPKIN, u);
|
||||||
tooth(c, cx - 120f * u, gt + 12f * u, 15f * u, 28f * u);
|
tooth(c, cx, 120f * u, gw, gt, 26f * u, 17f * u, gt + 40f * u, PUMPKIN_LIT, u);
|
||||||
tooth(c, cx + 120f * u, gt + 12f * u, 15f * u, 28f * u);
|
|
||||||
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, gb - 18f * u, 92f * u, INK, serif, Paint.Align.CENTER);
|
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, gb - 18f * u, 92f * u, INK, serif, Paint.Align.CENTER);
|
||||||
label(c, "KM/H", cx + 80f * u, cy + 64f * u, 16f * u, INK, face, Paint.Align.LEFT);
|
label(c, "KM/H", cx + 80f * u, cy + 64f * u, 16f * u, INK, face, Paint.Align.LEFT);
|
||||||
// The power under the pumpkin, above the ridge.
|
// The power under the pumpkin, above the ridge.
|
||||||
@@ -351,6 +362,13 @@ final class HalloweenView extends ClusterCanvasView {
|
|||||||
label(c, power, cx, cy + ry + 44f * u, 34f * u, regen ? FIRE : BONE, face, Paint.Align.CENTER);
|
label(c, power, cx, cy + ry + 44f * u, 34f * u, regen ? FIRE : BONE, face, Paint.Align.CENTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The colour moved {@code share} of the way to white. */
|
||||||
|
private static int brighter(int colour, float share) {
|
||||||
|
int r = (colour >> 16) & 0xFF, g = (colour >> 8) & 0xFF, b = colour & 0xFF;
|
||||||
|
return 0xFF000000 | (r + (int) ((255 - r) * share)) << 16 | (g + (int) ((255 - g) * share)) << 8
|
||||||
|
| (b + (int) ((255 - b) * share));
|
||||||
|
}
|
||||||
|
|
||||||
private void triangle(Canvas c, float x1, float y1, float x2, float y2, float x3, float y3, int burn, float u) {
|
private void triangle(Canvas c, float x1, float y1, float x2, float y2, float x3, float y3, int burn, float u) {
|
||||||
path.reset();
|
path.reset();
|
||||||
path.moveTo(x1, y1);
|
path.moveTo(x1, y1);
|
||||||
@@ -364,14 +382,27 @@ final class HalloweenView extends ClusterCanvasView {
|
|||||||
c.drawPath(path, line);
|
c.drawPath(path, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void tooth(Canvas c, float x, float y, float halfW, float tall) {
|
/** A triangle hanging from the grin's upper lip (a curve sagging by {@code sag}) to {@code tipY}. */
|
||||||
|
private void tooth(Canvas c, float cx, float dx, float gw, float gt, float sag, float halfW, float tipY, int colour, float u) {
|
||||||
|
float left = lip(dx - halfW, gw, gt, sag), right = lip(dx + halfW, gw, gt, sag);
|
||||||
path.reset();
|
path.reset();
|
||||||
path.moveTo(x - halfW, y - tall * 0.3f);
|
// A little above the lip, so none of its dark edge shows across the tooth's root.
|
||||||
path.lineTo(x + halfW, y - tall * 0.3f);
|
path.moveTo(cx + dx - halfW, left - 1.5f * u);
|
||||||
path.lineTo(x + halfW, y + tall * 0.7f);
|
path.lineTo(cx + dx + halfW, right - 1.5f * u);
|
||||||
path.lineTo(x - halfW, y + tall * 0.7f);
|
path.lineTo(cx + dx, tipY);
|
||||||
path.close();
|
path.close();
|
||||||
|
fill.setColor(colour);
|
||||||
c.drawPath(path, fill);
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(0xFF4A1F04);
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
c.drawLine(cx + dx - halfW, left + 2f * u, cx + dx, tipY, line);
|
||||||
|
c.drawLine(cx + dx + halfW, right + 2f * u, cx + dx, tipY, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The height of the grin's upper lip {@code dx} from the middle. */
|
||||||
|
private static float lip(float dx, float gw, float gt, float sag) {
|
||||||
|
float t = (gw - Math.abs(dx)) / (2f * gw);
|
||||||
|
return gt + 2f * t * (1f - t) * sag;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Left: the sign under the cobweb ----------
|
// ---------- Left: the sign under the cobweb ----------
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private boolean inFront;
|
private boolean inFront;
|
||||||
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
||||||
private View[] stylePanes;
|
private View[] stylePanes;
|
||||||
|
private MusicTap musicTap;
|
||||||
private View consumptionPane;
|
private View consumptionPane;
|
||||||
private View chargingPane;
|
private View chargingPane;
|
||||||
/** Display, battery, charging price and Wi-Fi. */
|
/** Display, battery, charging price and Wi-Fi. */
|
||||||
@@ -195,6 +196,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
@Override public void onPageSelected(int position) {
|
@Override public void onPageSelected(int position) {
|
||||||
markCurrentPage(position);
|
markCurrentPage(position);
|
||||||
applyOverviewFullScreen(position);
|
applyOverviewFullScreen(position);
|
||||||
|
updateMusicTap();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen());
|
findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen());
|
||||||
@@ -353,9 +355,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
mapRoads = new MapRoads(this);
|
mapRoads = new MapRoads(this);
|
||||||
gridMapView = new GridView(this, mapRoads);
|
gridMapView = new GridView(this, mapRoads);
|
||||||
visorView = new VisorView(this);
|
visorView = new VisorView(this);
|
||||||
|
HalloweenView halloweenView = new HalloweenView(this);
|
||||||
|
musicTap = new MusicTap(this);
|
||||||
|
halloweenView.setMusic(musicTap);
|
||||||
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
||||||
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
||||||
new QuestView(this), visorView, new EmpireView(this), new MuertosView(this), new HalloweenView(this));
|
new QuestView(this), visorView, new EmpireView(this), new MuertosView(this), halloweenView);
|
||||||
View[] canvasPanes = new View[canvasStyles.size()];
|
View[] canvasPanes = new View[canvasStyles.size()];
|
||||||
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
||||||
// Grid with map: its map layer goes under it, in the same pane.
|
// Grid with map: its map layer goes under it, in the same pane.
|
||||||
@@ -412,6 +417,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
MediaProbe.start(this);
|
MediaProbe.start(this);
|
||||||
refreshMediaProbe();
|
refreshMediaProbe();
|
||||||
});
|
});
|
||||||
|
logPane.findViewById(R.id.sound_probe_button).setOnClickListener(v -> {
|
||||||
|
SoundProbe.start(this);
|
||||||
|
refreshSoundProbe();
|
||||||
|
});
|
||||||
|
|
||||||
setUpPager();
|
setUpPager();
|
||||||
// Back on the page that was open when the activity was recreated (accent colour change).
|
// Back on the page that was open when the activity was recreated (accent colour change).
|
||||||
@@ -502,6 +511,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
uiHandler.post(overviewRefresh);
|
uiHandler.post(overviewRefresh);
|
||||||
inFront = true;
|
inFront = true;
|
||||||
updateMapLocation();
|
updateMapLocation();
|
||||||
|
updateMusicTap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -551,6 +561,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
uiHandler.removeCallbacks(overviewRefresh);
|
uiHandler.removeCallbacks(overviewRefresh);
|
||||||
inFront = false;
|
inFront = false;
|
||||||
updateMapLocation();
|
updateMapLocation();
|
||||||
|
updateMusicTap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1747,6 +1758,14 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
|
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
|
||||||
if (settingsPane != null) bindAccentColor();
|
if (settingsPane != null) bindAccentColor();
|
||||||
updateMapLocation();
|
updateMapLocation();
|
||||||
|
updateMusicTap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pumpkin listens only while it can be seen: the app in front, on the Overview, in Halloween. */
|
||||||
|
private void updateMusicTap() {
|
||||||
|
ViewPager2 pager = findViewById(R.id.content);
|
||||||
|
musicTap.setRunning(inFront && overviewStyle() == OverviewStyle.HALLOWEEN
|
||||||
|
&& pager != null && pager.getCurrentItem() == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2341,9 +2360,20 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
if (!status.isEmpty()) text.setText(String.join("\n", status));
|
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() {
|
private void refreshCallLog() {
|
||||||
refreshSignalRecorder();
|
refreshSignalRecorder();
|
||||||
refreshMediaProbe();
|
refreshMediaProbe();
|
||||||
|
refreshSoundProbe();
|
||||||
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
||||||
// Update steps first: after a failed install, they are what the driver came here for.
|
// Update steps first: after a failed install, they are what the driver came here for.
|
||||||
java.util.List<String> updates = UpdateLog.lines(this);
|
java.util.List<String> updates = UpdateLog.lines(this);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How strongly the Halloween pumpkin's light answers the music at this moment, from 0 to 1, out of
|
||||||
|
* the bass the audio analyser reports. What counts is the beat, not the loudness: each reading is
|
||||||
|
* placed between the bass's recent average and its recent peak, so quiet and loud songs both
|
||||||
|
* pulse and a steady hum settles at nothing. It rises fast and falls slowly, as a flame does. No
|
||||||
|
* reading or silence falls back to 0, so without music the light stays as it is. Android-free and
|
||||||
|
* unit-tested.
|
||||||
|
*/
|
||||||
|
final class MusicPulse {
|
||||||
|
|
||||||
|
/** Below this the bass counts as silence. */
|
||||||
|
static final float SILENT = 0.005f;
|
||||||
|
private static final float RISE_S = 0.03f, FALL_S = 0.25f, AVERAGE_S = 2f, PEAK_S = 4f;
|
||||||
|
|
||||||
|
private float average, peak, pulse;
|
||||||
|
|
||||||
|
/** One reading, {@code seconds} after the last; null when it failed. Returns the new pulse. */
|
||||||
|
float update(Float bass, float seconds) {
|
||||||
|
float target = 0f;
|
||||||
|
if (bass == null || bass.isNaN() || bass < SILENT) {
|
||||||
|
average = 0f;
|
||||||
|
peak = 0f;
|
||||||
|
} else {
|
||||||
|
average = average == 0f ? bass : average + (bass - average) * share(seconds, AVERAGE_S);
|
||||||
|
peak = Math.max(bass, peak - peak * share(seconds, PEAK_S));
|
||||||
|
if (peak > average) target = Math.max(0f, Math.min(1f, (bass - average) / (peak - average)));
|
||||||
|
}
|
||||||
|
pulse += (target - pulse) * share(seconds, target > pulse ? RISE_S : FALL_S);
|
||||||
|
if (pulse < 0.005f) pulse = 0f;
|
||||||
|
return pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float share(float seconds, float timeConstant) {
|
||||||
|
return Math.min(1f, Math.max(0f, seconds) / timeConstant);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.media.audiofx.Visualizer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to what the head unit plays for the Halloween pumpkin's light: Android's audio analyser
|
||||||
|
* on the output mix, its bass read 25 times a second on a thread of its own, made into a
|
||||||
|
* {@link MusicPulse}. Runs only while MainActivity says so (the style on screen, the app in
|
||||||
|
* front). Levels only: no sound is kept and no microphone used. Where the analyser hears nothing
|
||||||
|
* (the radio) or is not available, the pulse stays 0 and the pumpkin burns as usual.
|
||||||
|
*/
|
||||||
|
final class MusicTap {
|
||||||
|
|
||||||
|
private static final int READ_MS = 40, GIVE_UP_AFTER_FAILURES = 50;
|
||||||
|
|
||||||
|
private final Context app;
|
||||||
|
private volatile Thread worker;
|
||||||
|
private volatile float pulse;
|
||||||
|
|
||||||
|
MusicTap(Context context) {
|
||||||
|
app = context.getApplicationContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From 0 to 1; safe to read from any thread. */
|
||||||
|
float pulse() {
|
||||||
|
return pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRunning(boolean run) {
|
||||||
|
if (run == (worker != null)) return;
|
||||||
|
if (!run) {
|
||||||
|
Thread stopping = worker;
|
||||||
|
worker = null;
|
||||||
|
pulse = 0f;
|
||||||
|
stopping.interrupt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (app.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) return;
|
||||||
|
Thread thread = new Thread(this::listen, "music-tap");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
worker = thread;
|
||||||
|
thread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All analyser calls live here, off the main thread; it is always let go. */
|
||||||
|
private void listen() {
|
||||||
|
Thread me = Thread.currentThread();
|
||||||
|
Visualizer analyser = null;
|
||||||
|
try {
|
||||||
|
analyser = new Visualizer(0);
|
||||||
|
analyser.setCaptureSize(Visualizer.getCaptureSizeRange()[1]);
|
||||||
|
analyser.setScalingMode(Visualizer.SCALING_MODE_NORMALIZED);
|
||||||
|
if (analyser.setEnabled(true) != Visualizer.SUCCESS) return;
|
||||||
|
int rateHz = analyser.getSamplingRate() / 1000;
|
||||||
|
byte[] fft = new byte[analyser.getCaptureSize()];
|
||||||
|
MusicPulse music = new MusicPulse();
|
||||||
|
long last = System.nanoTime();
|
||||||
|
int failures = 0;
|
||||||
|
while (worker == me && failures < GIVE_UP_AFTER_FAILURES) {
|
||||||
|
Thread.sleep(READ_MS);
|
||||||
|
boolean read = analyser.getFft(fft) == Visualizer.SUCCESS;
|
||||||
|
failures = read ? 0 : failures + 1;
|
||||||
|
long now = System.nanoTime();
|
||||||
|
float value = music.update(read ? SoundLevels.band(fft, rateHz, 20f, 250f) : null, (now - last) / 1e9f);
|
||||||
|
last = now;
|
||||||
|
if (worker == me) pulse = value;
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
// Told to stop.
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// No analyser here: the pumpkin burns as usual.
|
||||||
|
} finally {
|
||||||
|
if (worker == me) pulse = 0f;
|
||||||
|
if (analyser != null) {
|
||||||
|
try {
|
||||||
|
analyser.setEnabled(false);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Released next either way.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
analyser.release();
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Nothing more can be done from here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
<com.google.android.material.button.MaterialButton android:id="@+id/media_probe_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_media_probe" style="@style/Widget.EV.Button.Outlined" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/media_probe_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_media_probe" style="@style/Widget.EV.Button.Outlined" />
|
||||||
<TextView android:id="@+id/media_probe_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
|
<TextView android:id="@+id/media_probe_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
<!-- The sound check (SoundProbe): whether the audio analyser can hear what plays. -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:layout_marginBottom="16dp" android:background="@drawable/bg_glass">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_sound_probe" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/sound_probe_hint" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
|
<com.google.android.material.button.MaterialButton android:id="@+id/sound_probe_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_sound_probe" style="@style/Widget.EV.Button.Outlined" />
|
||||||
|
<TextView android:id="@+id/sound_probe_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" />
|
||||||
|
|||||||
@@ -233,6 +233,10 @@
|
|||||||
<string name="label_media_probe">Music sources</string>
|
<string name="label_media_probe">Music sources</string>
|
||||||
<string name="media_probe_hint">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.</string>
|
<string name="media_probe_hint">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.</string>
|
||||||
<string name="btn_media_probe">Check music sources</string>
|
<string name="btn_media_probe">Check music sources</string>
|
||||||
|
<string name="label_sound_probe">Sound check</string>
|
||||||
|
<string name="sound_probe_hint">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.</string>
|
||||||
|
<string name="btn_sound_probe">Check sound</string>
|
||||||
|
<string name="sound_probe_running">Listening… (10 seconds)</string>
|
||||||
<string name="media_probe_running">Checking… (up to a minute)</string>
|
<string name="media_probe_running">Checking… (up to a minute)</string>
|
||||||
<string name="media_probe_done">Check from %1$s, %2$s:</string>
|
<string name="media_probe_done">Check from %1$s, %2$s:</string>
|
||||||
<string name="log_outbox_sent">Sent to the server.</string>
|
<string name="log_outbox_sent">Sent to the server.</string>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class MusicPulseTest {
|
||||||
|
|
||||||
|
private static final float STEP = 0.04f;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void noReadingAndSilenceLightNothing() {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
assertEquals(0f, p.update(null, STEP), 0f);
|
||||||
|
assertEquals(0f, p.update(Float.NaN, STEP), 0f);
|
||||||
|
assertEquals(0f, p.update(0f, STEP), 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aBeatLightsAndFadesSlowerThanItRises() {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
for (int i = 0; i < 50; i++) p.update(0.05f, STEP);
|
||||||
|
float beat = p.update(0.5f, STEP);
|
||||||
|
assertTrue("a beat lights it, got " + beat, beat > 0.8f);
|
||||||
|
float after = p.update(0.05f, STEP);
|
||||||
|
assertTrue("it fades, got " + after, after < beat);
|
||||||
|
assertTrue("slowly, got " + after, after > beat * 0.7f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aSteadyHumSettlesAtNothing() {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
float last = 1f;
|
||||||
|
for (int i = 0; i < 400; i++) last = p.update(0.3f, STEP);
|
||||||
|
assertEquals(0f, last, 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void quietAndLoudSongsPulseAlike() {
|
||||||
|
assertEquals(beatOf(0.5f), beatOf(0.05f), 0.02f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float beatOf(float scale) {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
float top = 0f;
|
||||||
|
for (int i = 0; i < 500; i++) {
|
||||||
|
top = Math.max(top * 0.99f, p.update(i % 12 == 0 ? scale : scale * 0.2f, STEP));
|
||||||
|
}
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void whenTheMusicStopsItGoesOut() {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
for (int i = 0; i < 100; i++) p.update(i % 12 == 0 ? 0.5f : 0.1f, STEP);
|
||||||
|
float last = 1f;
|
||||||
|
for (int i = 0; i < 100; i++) last = p.update(null, STEP);
|
||||||
|
assertEquals(0f, last, 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void itStaysBetweenZeroAndOne() {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
for (int i = 0; i < 2000; i++) {
|
||||||
|
Float bass = i % 11 == 0 ? null : Float.valueOf(i % 7 == 0 ? 1f : 0.1f);
|
||||||
|
float v = p.update(bass, i % 5 == 0 ? 2f : STEP);
|
||||||
|
assertTrue(v >= 0f && v <= 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@
|
|||||||
android:name="android.permission.MEDIA_CONTENT_CONTROL"
|
android:name="android.permission.MEDIA_CONTENT_CONTROL"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|
||||||
|
<!-- The Logs tab's sound check (SoundProbe): Android's audio analyser asks for both. The
|
||||||
|
system user holds them without a prompt; the analyser reports levels only, no sound. -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 334 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 304 KiB |
Reference in New Issue
Block a user