App 2.43.4: the Halloween pumpkin's light follows the music
The bass from Android's audio analyser drives the glow and brightens the carving on the beat, only while the style is on screen. Yellow and green and the embers keep their meaning; without a signal, as with the radio, the light stays as it was. Levels only. Tried on the emulator. Not tried on the car. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user