Tapping the TAP button records timestamps and calculates BPM from the
average interval between the last few taps (within a 3 second window).
After 2 seconds of no tapping the window resets. BPM is clamped to the
40-200 range and immediately applied to the running service if active.
The TAP button sits to the left of START/STOP in a shared row, taking
1/3 of the width; START/STOP takes the remaining 2/3.
A square curve still compresses too much at the top half of the range.
Map the slider linearly to -40 dB … 0 dB, then convert to amplitude
with 10^(dB/20). This makes each equal step of the slider produce an
equal perceived loudness change, so the full 0–100% range feels usable.
AudioTrack.setVolume() takes a linear amplitude value, but human hearing
is logarithmic — a linear 0-1 slider sounds like all the volume is
crammed into the bottom quarter of the range. Squaring the slider value
(amplitude = linear²) spreads perceived loudness evenly across the
full slider travel.
Detect orientation via LocalConfiguration and switch to a Row-based
two-column layout in landscape mode. Left column shows the title, BPM
display and pulse ring; right column shows the BPM slider, presets,
volume slider and the START/STOP button. All controls remain visible
without scrolling. Portrait layout is unchanged.
Extracted shared UI into private composables (Title, BpmDisplay,
PulseRing, BpmControls, VolumeControl, StartStopButton) to avoid
duplication between the two layout paths.
Signing config reads from keystore.properties (gitignored).
Release build has minification enabled. The keystore and
properties file are excluded from version control.
To build a signed release APK:
./gradlew assembleRelease
Output: app/build/outputs/apk/release/app-release.apk
Adaptive icon (API 26+) with dark background and lime-yellow
pulse ring + center dot matching the UI aesthetic. Legacy PNG
icons generated at all densities (mdpi through xxxhdpi).
- Layout order: title → BPM → pulse ring → SLOW/FAST slider
→ presets → VOL slider → START/STOP (full-width, at bottom)
- 'steps / min' replaces 'BPM' under the number
- 'X.X steps/sec' calculated sub-label added
- Slider flanked by SLOW / FAST labels
- Volume shows 'VOL' left and '60%' right
- Default BPM 130, default volume 60% to match original
MainActivity now requests battery optimization exemption on first
launch so the foreground service survives Doze mode during long
walks. The REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission was
already declared in the manifest.
Unit test coverage confirmed across all core logic:
BeepGeneratorTest (4), BpmCalculatorTest (6), SettingsRepositoryTest (6).
SettingsRepository wraps SharedPreferences with typed save/load
for BPM (default 120) and volume (default 1.0). PacerViewModel
loads saved values on init and saves on every change.
Bundle JetBrains Mono Regular and Bold TTF files for a modern
monospace look without needing network access. All buttons now
use RectangleShape to match the sharp-cornered style of the
original web version.
- Black (#0a0a0a) background, off-white (#f0ede8) text,
lime-yellow (#e8ff47) accent, monospace font throughout
- BPM number flashes to accent on each beat
- Pulse ring with expanding ripple and scaling center dot
- Linear progress bar sweeps across the bottom each interval
- Subtle scanline overlay for retro CRT effect
- START/STOP button inverts to accent when active
BeepGenerator: add 5ms attack / 10ms release envelope so the
waveform ramps in/out cleanly instead of cutting off mid-cycle,
which caused the audible click/distortion.
AudioEngine: replace postDelayed scheduling with a continuous
write loop — each iteration writes the full beep followed by
silence in 10ms chunks. This keeps the AudioTrack stream full
and eliminates underrun noise. onBeep callback fires on the
main thread just before each beep is queued.
PacerService: wire onBeep to increment beepTick (Compose state).
PacerScreen: replace the independent BPM timer with beepTick so
the pulse ring animation is driven by the actual audio callback,
eliminating the drift at slow paces.
PacerService owns AudioEngine and a PARTIAL_WAKE_LOCK so audio
survives screen lock and Doze during long walks. Foreground
notification shows current BPM and a Stop action button.
PacerViewModel becomes AndroidViewModel and communicates with
the service via intents, keeping AudioEngine off the UI process.
Service uses START_NOT_STICKY so it does not restart if killed.
Dark-themed Compose screen with:
- Large monospace BPM counter and pace slider (40-200 BPM)
- Six preset buttons (80/100/120/130/140/160 BPM)
- Volume slider with percentage readout
- Circular start/stop button with green pulse ring that
animates on each beat via a coroutine-driven LaunchedEffect
- PacerViewModel owns AudioEngine lifecycle so audio survives
configuration changes
BpmCalculator provides interval math (60000 / bpm) and volume
clamping (0.0-1.0). AudioEngine drives AudioTrack on a dedicated
HandlerThread, scheduling beeps via postDelayed for battery-
efficient background playback.
Audio focus is intentionally never requested so beeps play
alongside other apps without ducking (US-05).
Initial project structure with manifest configured for foreground
audio service, AudioTrack-based playback, and background operation.
Includes Gradle wrapper, dependency catalog, placeholder icons,
and build instructions for Arch Linux.