App 2.44.0: the pumpkin light follows the kick drum, behind a switch

MusicPulse reads 40-120 Hz and lights only on jumps of the bass, so
held notes and bass lines stay dark. Settings gets "Pumpkin light
follows the music" (Halloween only, off by default); off, the audio
analyser never starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-11-02 13:15:25 +01:00
co-authored by Claude Opus 5.5
parent 418883beb0
commit 9d84dc6d15
11 changed files with 97 additions and 20 deletions
@@ -215,6 +215,8 @@ public class MainActivity extends AppCompatActivity {
/** Whether the Overview hides the navigation (the button in the navigation's header). */
static final String PREF_OVERVIEW_FULLSCREEN = "overview_fullscreen";
/** The Halloween pumpkin's light follows the music; off by default, so the analyser stays off. */
static final String PREF_MUSIC_LIGHT = "music_light";
/** True while the Overview is full screen: the navigation hidden, the edge swipe armed. */
private boolean overviewFull;
@@ -1223,6 +1225,12 @@ public class MainActivity extends AppCompatActivity {
bindDriveMode();
bindOverviewStyle();
bindAccentColor();
SwitchMaterial musicLight = settingsPane.findViewById(R.id.music_light_switch);
musicLight.setChecked(prefs.getBoolean(PREF_MUSIC_LIGHT, false));
musicLight.setOnCheckedChangeListener((btn, checked) -> {
prefs.edit().putBoolean(PREF_MUSIC_LIGHT, checked).apply();
updateMusicTap();
});
fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch);
fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs));
fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> {
@@ -1761,10 +1769,14 @@ public class MainActivity extends AppCompatActivity {
updateMusicTap();
}
/** The pumpkin listens only while it can be seen: the app in front, on the Overview, in Halloween. */
/**
* The pumpkin listens only while it can be seen (the app in front, on the Overview, in
* Halloween) and only when "Pumpkin light follows the music" is on.
*/
private void updateMusicTap() {
ViewPager2 pager = findViewById(R.id.content);
musicTap.setRunning(inFront && overviewStyle() == OverviewStyle.HALLOWEEN
&& prefs.getBoolean(PREF_MUSIC_LIGHT, false)
&& pager != null && pager.getCurrentItem() == 0);
}
@@ -1828,6 +1840,9 @@ public class MainActivity extends AppCompatActivity {
TextView note = settingsPane.findViewById(R.id.accent_note);
note.setVisibility(style.usesAccent ? View.GONE : View.VISIBLE);
note.setText(getString(R.string.accent_not_used, styleLabels()[style.ordinal()]));
int music = style == OverviewStyle.HALLOWEEN ? View.VISIBLE : View.GONE;
settingsPane.findViewById(R.id.music_light_switch).setVisibility(music);
settingsPane.findViewById(R.id.music_light_hint).setVisibility(music);
}
/** The drop-down's labels, by {@link OverviewStyle} ordinal. */
@@ -2,32 +2,45 @@ 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.
* the kick drum's band the audio analyser reports. What counts is the hit, not the loudness: only
* a jump of the bass since the last reading lights it, measured against the biggest jumps of the
* last seconds, so quiet and loud songs both flash, and a held bass note or a steady hum does not.
* Jumps that are small against the bass's own level (a swelling note, noise) are ignored. It
* flares at once and dies down within a beat, 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;
/** The kick drum's band, in Hz, that {@link MusicTap} reads. */
static final float FROM_HZ = 40f, TO_HZ = 120f;
private static final float FALL_S = 0.2f, AVERAGE_S = 2f, PEAK_S = 4f;
/** A jump below this share of the recent biggest one lights nothing. */
private static final float THRESHOLD = 0.2f;
/** A jump has to be at least this share of the bass's average level to count at all. */
private static final float LEVEL_SHARE = 0.5f;
private float average, peak, pulse;
private float last, average, peakJump, 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) {
last = 0f;
average = 0f;
peak = 0f;
peakJump = 0f;
} else {
float jump = Math.max(0f, bass - last);
last = bass;
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)));
peakJump = Math.max(jump, peakJump - peakJump * share(seconds, PEAK_S));
float reference = Math.max(peakJump, average * LEVEL_SHARE);
if (reference > 0f) {
target = Math.max(0f, Math.min(1f, (jump / reference - THRESHOLD) / (1f - THRESHOLD)));
}
}
pulse += (target - pulse) * share(seconds, target > pulse ? RISE_S : FALL_S);
pulse = target > pulse ? target : pulse + (target - pulse) * share(seconds, FALL_S);
if (pulse < 0.005f) pulse = 0f;
return pulse;
}
@@ -7,9 +7,9 @@ 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
* on the output mix, its kick-drum band 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
* front, and "Pumpkin light follows the music" on). 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 {
@@ -64,7 +64,7 @@ final class MusicTap {
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);
float value = music.update(read ? SoundLevels.band(fft, rateHz, MusicPulse.FROM_HZ, MusicPulse.TO_HZ) : null, (now - last) / 1e9f);
last = now;
if (worker == me) pulse = value;
}