diff --git a/AGENTS.md b/AGENTS.md index 87501d5..6797fc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), | 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 | | 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 | diff --git a/SPECS.md b/SPECS.md index df5e25e..8d47b32 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-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. diff --git a/THEMES.md b/THEMES.md index 90aa10c..c44e033 100644 --- a/THEMES.md +++ b/THEMES.md @@ -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, 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 -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. ![Halloween with the navigation](screenshots/themes/halloween-panel.png) ![Halloween, full screen](screenshots/themes/halloween.png) diff --git a/app/build.gradle b/app/build.gradle index eb1cc41..bc1f7f7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 156 - versionName "2.43.3" + versionCode 157 + versionName "2.43.4" // 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/HalloweenView.java b/app/src/main/java/com/evsuite/abrp/HalloweenView.java index 4e87aea..06572ae 100644 --- a/app/src/main/java/com/evsuite/abrp/HalloweenView.java +++ b/app/src/main/java/com/evsuite/abrp/HalloweenView.java @@ -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 * 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 - * 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 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 { @@ -56,6 +59,7 @@ final class HalloweenView extends ClusterCanvasView { private int skyW, skyH; private float time; private long lastFrame; + private MusicTap music; HalloweenView(Context context) { super(context); @@ -63,6 +67,11 @@ final class HalloweenView extends ClusterCanvasView { 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 protected void onDetachedFromWindow() { super.onDetachedFromWindow(); @@ -91,12 +100,13 @@ final class HalloweenView extends ClusterCanvasView { drawGhost(c, w, u); drawHill(c, w, h, u); drawEmbers(c, w, u); - drawPumpkin(c, w, u); + float pulse = music == null ? 0f : music.pulse(); + drawPumpkin(c, w, u, pulse); drawSign(c, u); drawFlask(c, u); drawTombstone(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) { @@ -289,16 +299,18 @@ final class HalloweenView extends ClusterCanvasView { // ---------- 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; boolean regen = regen(); - int burn = regen ? FIRE : CANDLE; - // The light it throws, stronger the more power. + // The carving brightens on the beat, gently, and stays yellow or green. + 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); - glow.setShader(new RadialGradient(cx, cy, rx * 1.6f, - new int[] { AccentColor.withAlpha(regen ? FIRE : ORANGE, 0x30 + (int) (share * 0x50)), + float reach = rx * (1.6f + pulse * 0.2f); + 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)); - c.drawCircle(cx, cy, rx * 1.6f, glow); + c.drawCircle(cx, cy, reach, glow); // The stem. path.reset(); path.moveTo(cx - 22f * u, cy - ry + 12f * u); @@ -350,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); } + /** 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) { path.reset(); path.moveTo(x1, y1); diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 50836e5..a081ab0 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -77,6 +77,7 @@ public class MainActivity extends AppCompatActivity { private boolean inFront; /** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */ private View[] stylePanes; + private MusicTap musicTap; private View consumptionPane; private View chargingPane; /** Display, battery, charging price and Wi-Fi. */ @@ -195,6 +196,7 @@ public class MainActivity extends AppCompatActivity { @Override public void onPageSelected(int position) { markCurrentPage(position); applyOverviewFullScreen(position); + updateMusicTap(); } }); findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen()); @@ -353,9 +355,12 @@ public class MainActivity extends AppCompatActivity { mapRoads = new MapRoads(this); gridMapView = new GridView(this, mapRoads); 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, 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()]; 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. @@ -506,6 +511,7 @@ public class MainActivity extends AppCompatActivity { uiHandler.post(overviewRefresh); inFront = true; updateMapLocation(); + updateMusicTap(); } @Override @@ -555,6 +561,7 @@ public class MainActivity extends AppCompatActivity { uiHandler.removeCallbacks(overviewRefresh); inFront = false; updateMapLocation(); + updateMusicTap(); } @Override @@ -1751,6 +1758,14 @@ public class MainActivity extends AppCompatActivity { // The accent choices depend on the style ("Original colours" for Retro and Terminal). if (settingsPane != null) bindAccentColor(); 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); } /** diff --git a/app/src/main/java/com/evsuite/abrp/MusicPulse.java b/app/src/main/java/com/evsuite/abrp/MusicPulse.java new file mode 100644 index 0000000..17a5e41 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/MusicPulse.java @@ -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); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MusicTap.java b/app/src/main/java/com/evsuite/abrp/MusicTap.java new file mode 100644 index 0000000..1dead7e --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/MusicTap.java @@ -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. + } + } + } + } +} diff --git a/app/src/test/java/com/evsuite/abrp/MusicPulseTest.java b/app/src/test/java/com/evsuite/abrp/MusicPulseTest.java new file mode 100644 index 0000000..673ffec --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/MusicPulseTest.java @@ -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); + } + } +}