feat: add TTS voice guidance during GPS-tracked workouts

Voice announcements run entirely in the Android foreground service
(works with screen locked). Configurable via web UI before starting
GPS: time-based or distance-based intervals, selectable metrics
(total time, distance, avg/split/current pace), language (en/de).

Also syncs workout pause/resume state to the native service — pausing
the workout timer now freezes the Android-side elapsed time, distance
accumulation, and TTS triggers.

Includes TTS engine detection with install prompt if none found, and
Android 11+ package visibility query for TTS service discovery.
This commit is contained in:
2026-03-25 10:13:12 +01:00
parent a5f2a1d6de
commit d75e2354f6
5 changed files with 660 additions and 21 deletions

View File

@@ -11,6 +11,12 @@
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"

View File

@@ -6,14 +6,20 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.speech.tts.TextToSpeech
import android.webkit.JavascriptInterface
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
class AndroidBridge(private val context: Context) {
private var ttsForVoices: TextToSpeech? = null
@JavascriptInterface
fun startLocationService() {
fun startLocationService(ttsConfigJson: String) {
if (context is Activity) {
// Request notification permission on Android 13+ (required for foreground service notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -42,7 +48,9 @@ class AndroidBridge(private val context: Context) {
}
}
val intent = Intent(context, LocationForegroundService::class.java)
val intent = Intent(context, LocationForegroundService::class.java).apply {
putExtra("ttsConfig", ttsConfigJson)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
@@ -50,6 +58,12 @@ class AndroidBridge(private val context: Context) {
}
}
/** Backwards-compatible overload for calls without TTS config */
@JavascriptInterface
fun startLocationService() {
startLocationService("{}")
}
@JavascriptInterface
fun stopLocationService() {
val intent = Intent(context, LocationForegroundService::class.java)
@@ -65,4 +79,62 @@ class AndroidBridge(private val context: Context) {
fun isTracking(): Boolean {
return LocationForegroundService.tracking
}
@JavascriptInterface
fun pauseTracking() {
LocationForegroundService.instance?.doPause()
}
@JavascriptInterface
fun resumeTracking() {
LocationForegroundService.instance?.doResume()
}
/** Returns true if at least one TTS engine is installed on the device. */
@JavascriptInterface
fun hasTtsEngine(): Boolean {
val dummy = TextToSpeech(context, null)
val hasEngine = dummy.engines.isNotEmpty()
dummy.shutdown()
return hasEngine
}
/** Opens the Android TTS install intent (prompts user to install a TTS engine). */
@JavascriptInterface
fun installTtsEngine() {
val intent = Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
/**
* Returns available TTS voices as a JSON array.
* Each entry: { "id": "...", "name": "...", "language": "en-US" }
* This initializes a temporary TTS engine; the result is returned asynchronously
* via a callback, but since @JavascriptInterface is synchronous we block briefly.
*/
@JavascriptInterface
fun getAvailableTtsVoices(): String {
val result = JSONArray()
try {
val latch = java.util.concurrent.CountDownLatch(1)
var engine: TextToSpeech? = null
engine = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
engine?.voices?.forEach { voice ->
val obj = JSONObject().apply {
put("id", voice.name)
put("name", voice.name)
put("language", voice.locale.toLanguageTag())
}
result.put(obj)
}
}
latch.countDown()
}
latch.await(3, java.util.concurrent.TimeUnit.SECONDS)
engine.shutdown()
} catch (_: Exception) {}
return result.toString()
}
}

View File

@@ -10,24 +10,77 @@ import android.content.Intent
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.speech.tts.TextToSpeech
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.util.Collections
import java.util.Locale
import kotlin.math.*
class LocationForegroundService : Service() {
private const val TAG = "BockenTTS"
class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
private var locationManager: LocationManager? = null
private var locationListener: LocationListener? = null
private var notificationManager: NotificationManager? = null
private var pendingIntent: PendingIntent? = null
private var startTimeMs: Long = 0L
private var pausedAccumulatedMs: Long = 0L // total time spent paused
private var pausedSinceMs: Long = 0L // timestamp when last paused (0 = not paused)
private var lastLat: Double = Double.NaN
private var lastLng: Double = Double.NaN
private var lastTimestamp: Long = 0L
private var currentPaceMinKm: Double = 0.0
// TTS
private var tts: TextToSpeech? = null
private var ttsReady = false
private var ttsConfig: TtsConfig? = null
private var ttsTimeHandler: Handler? = null
private var ttsTimeRunnable: Runnable? = null
private var lastAnnouncementDistanceKm: Double = 0.0
private var lastAnnouncementTimeMs: Long = 0L
private var splitDistanceAtLastAnnouncement: Double = 0.0
private var splitTimeAtLastAnnouncement: Long = 0L
data class TtsConfig(
val enabled: Boolean = false,
val triggerType: String = "distance", // "distance" or "time"
val triggerValue: Double = 1.0, // km or minutes
val metrics: List<String> = listOf("totalTime", "totalDistance", "avgPace"),
val language: String = "en",
val voiceId: String? = null
) {
companion object {
fun fromJson(json: String): TtsConfig {
return try {
val obj = JSONObject(json)
val metricsArr = obj.optJSONArray("metrics")
val metrics = if (metricsArr != null) {
(0 until metricsArr.length()).map { metricsArr.getString(it) }
} else {
listOf("totalTime", "totalDistance", "avgPace")
}
TtsConfig(
enabled = obj.optBoolean("enabled", false),
triggerType = obj.optString("triggerType", "distance"),
triggerValue = obj.optDouble("triggerValue", 1.0),
metrics = metrics,
language = obj.optString("language", "en"),
voiceId = obj.optString("voiceId", null)
)
} catch (_: Exception) {
TtsConfig()
}
}
}
}
companion object {
const val CHANNEL_ID = "gps_tracking"
const val NOTIFICATION_ID = 1001
@@ -35,8 +88,12 @@ class LocationForegroundService : Service() {
const val MIN_DISTANCE_M = 0f
private val pointBuffer = Collections.synchronizedList(mutableListOf<JSONObject>())
var instance: LocationForegroundService? = null
private set
var tracking = false
private set
var paused = false
private set
var totalDistanceKm: Double = 0.0
private set
@@ -71,12 +128,21 @@ class LocationForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startTimeMs = System.currentTimeMillis()
pausedAccumulatedMs = 0L
pausedSinceMs = 0L
paused = false
totalDistanceKm = 0.0
lastLat = Double.NaN
lastLng = Double.NaN
lastTimestamp = 0L
currentPaceMinKm = 0.0
// Parse TTS config from intent
val configJson = intent?.getStringExtra("ttsConfig") ?: "{}"
Log.d(TAG, "TTS config JSON: $configJson")
ttsConfig = TtsConfig.fromJson(configJson)
Log.d(TAG, "TTS enabled=${ttsConfig?.enabled}, trigger=${ttsConfig?.triggerType}/${ttsConfig?.triggerValue}, metrics=${ttsConfig?.metrics}")
val notifIntent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
@@ -95,10 +161,200 @@ class LocationForegroundService : Service() {
startLocationUpdates()
tracking = true
instance = this
// Initialize TTS *after* startForeground — using applicationContext for reliable engine binding
if (ttsConfig?.enabled == true) {
Log.d(TAG, "Initializing TTS engine (post-startForeground)...")
lastAnnouncementDistanceKm = 0.0
lastAnnouncementTimeMs = startTimeMs
splitDistanceAtLastAnnouncement = 0.0
splitTimeAtLastAnnouncement = startTimeMs
// Log available TTS engines
val dummyTts = TextToSpeech(applicationContext, null)
val engines = dummyTts.engines
Log.d(TAG, "Available TTS engines: ${engines.map { "${it.label} (${it.name})" }}")
dummyTts.shutdown()
// Try with explicit engine if available
if (engines.isNotEmpty()) {
val engineName = engines[0].name
Log.d(TAG, "Trying TTS with explicit engine: $engineName")
tts = TextToSpeech(applicationContext, this, engineName)
} else {
Log.e(TAG, "No TTS engines found on device!")
tts = TextToSpeech(applicationContext, this)
}
}
return START_STICKY
}
// --- TTS ---
override fun onInit(status: Int) {
Log.d(TAG, "TTS onInit status=$status (SUCCESS=${TextToSpeech.SUCCESS})")
if (status == TextToSpeech.SUCCESS) {
val config = ttsConfig ?: return
val locale = Locale.forLanguageTag(config.language)
val langResult = tts?.setLanguage(locale)
Log.d(TAG, "TTS setLanguage($locale) result=$langResult")
// Set specific voice if requested
if (!config.voiceId.isNullOrEmpty()) {
tts?.voices?.find { it.name == config.voiceId }?.let { voice ->
tts?.voice = voice
}
}
ttsReady = true
Log.d(TAG, "TTS ready! triggerType=${config.triggerType}, triggerValue=${config.triggerValue}")
// Set up time-based trigger if configured
if (config.triggerType == "time") {
startTimeTrigger(config.triggerValue)
}
} else {
Log.e(TAG, "TTS init FAILED with status=$status")
}
}
private fun startTimeTrigger(intervalMinutes: Double) {
val intervalMs = (intervalMinutes * 60 * 1000).toLong()
Log.d(TAG, "Starting time trigger: every ${intervalMs}ms (${intervalMinutes} min)")
ttsTimeHandler = Handler(Looper.getMainLooper())
ttsTimeRunnable = object : Runnable {
override fun run() {
Log.d(TAG, "Time trigger fired!")
announceMetrics()
ttsTimeHandler?.postDelayed(this, intervalMs)
}
}
ttsTimeHandler?.postDelayed(ttsTimeRunnable!!, intervalMs)
}
// --- Pause / Resume ---
fun doPause() {
if (paused) return
paused = true
pausedSinceMs = System.currentTimeMillis()
Log.d(TAG, "Tracking paused")
// Pause TTS time trigger
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
// Update notification to show paused state
val notification = buildNotification(formatElapsed(), "%.2f km".format(totalDistanceKm), "PAUSED")
notificationManager?.notify(NOTIFICATION_ID, notification)
}
fun doResume() {
if (!paused) return
// Accumulate paused duration
pausedAccumulatedMs += System.currentTimeMillis() - pausedSinceMs
pausedSinceMs = 0L
paused = false
Log.d(TAG, "Tracking resumed (total paused: ${pausedAccumulatedMs / 1000}s)")
// Reset last position so we don't accumulate drift during pause
lastLat = Double.NaN
lastLng = Double.NaN
lastTimestamp = 0L
// Resume TTS time trigger
val config = ttsConfig
if (ttsReady && config != null && config.triggerType == "time") {
val intervalMs = (config.triggerValue * 60 * 1000).toLong()
ttsTimeRunnable?.let { ttsTimeHandler?.postDelayed(it, intervalMs) }
}
updateNotification()
}
private fun checkDistanceTrigger() {
val config = ttsConfig ?: return
if (!ttsReady || config.triggerType != "distance") return
val sinceLast = totalDistanceKm - lastAnnouncementDistanceKm
if (sinceLast >= config.triggerValue) {
announceMetrics()
lastAnnouncementDistanceKm = totalDistanceKm
}
}
private fun announceMetrics() {
if (!ttsReady) return
val config = ttsConfig ?: return
val now = System.currentTimeMillis()
val activeSecs = activeElapsedSecs()
val parts = mutableListOf<String>()
for (metric in config.metrics) {
when (metric) {
"totalTime" -> {
val h = activeSecs / 3600
val m = (activeSecs % 3600) / 60
val s = activeSecs % 60
val timeStr = if (h > 0) {
"$h hours $m minutes"
} else {
"$m minutes $s seconds"
}
parts.add("Time: $timeStr")
}
"totalDistance" -> {
val distStr = "%.2f".format(totalDistanceKm)
parts.add("Distance: $distStr kilometers")
}
"avgPace" -> {
val elapsedMin = activeSecs / 60.0
if (totalDistanceKm > 0.01) {
val avgPace = elapsedMin / totalDistanceKm
val mins = avgPace.toInt()
val secs = ((avgPace - mins) * 60).toInt()
parts.add("Average pace: $mins minutes $secs seconds per kilometer")
}
}
"splitPace" -> {
val splitDist = totalDistanceKm - splitDistanceAtLastAnnouncement
val splitTimeMin = (now - splitTimeAtLastAnnouncement) / 60000.0
if (splitDist > 0.01) {
val splitPace = splitTimeMin / splitDist
val mins = splitPace.toInt()
val secs = ((splitPace - mins) * 60).toInt()
parts.add("Split pace: $mins minutes $secs seconds per kilometer")
}
}
"currentPace" -> {
if (currentPaceMinKm > 0 && currentPaceMinKm <= 60) {
val mins = currentPaceMinKm.toInt()
val secs = ((currentPaceMinKm - mins) * 60).toInt()
parts.add("Current pace: $mins minutes $secs seconds per kilometer")
}
}
}
}
// Update split tracking
splitDistanceAtLastAnnouncement = totalDistanceKm
splitTimeAtLastAnnouncement = now
lastAnnouncementTimeMs = now
if (parts.isNotEmpty()) {
val text = parts.joinToString(". ")
Log.d(TAG, "Announcing: $text")
val result = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "workout_announcement")
Log.d(TAG, "TTS speak() result=$result (SUCCESS=${TextToSpeech.SUCCESS})")
} else {
Log.d(TAG, "announceMetrics: no parts to announce")
}
}
// --- Notification / Location (unchanged) ---
private fun formatPace(paceMinKm: Double): String {
if (paceMinKm <= 0 || paceMinKm > 60) return ""
val mins = paceMinKm.toInt()
@@ -130,8 +386,15 @@ class LocationForegroundService : Service() {
}
}
/** Returns active (non-paused) elapsed time in seconds. */
private fun activeElapsedSecs(): Long {
val now = System.currentTimeMillis()
val totalPaused = pausedAccumulatedMs + if (pausedSinceMs > 0) (now - pausedSinceMs) else 0L
return (now - startTimeMs - totalPaused) / 1000
}
private fun formatElapsed(): String {
val secs = (System.currentTimeMillis() - startTimeMs) / 1000
val secs = activeElapsedSecs()
val h = secs / 3600
val m = (secs % 3600) / 60
val s = secs % 60
@@ -158,9 +421,22 @@ class LocationForegroundService : Service() {
locationListener = LocationListener { location ->
val lat = location.latitude
val lng = location.longitude
val now = location.time
// Always buffer GPS points (for track drawing) even when paused
val point = JSONObject().apply {
put("lat", lat)
put("lng", lng)
if (location.hasAltitude()) put("altitude", location.altitude)
if (location.hasSpeed()) put("speed", location.speed.toDouble())
put("timestamp", location.time)
}
pointBuffer.add(point)
// Skip distance/pace accumulation and TTS triggers when paused
if (paused) return@LocationListener
// Accumulate distance and compute pace
val now = location.time
if (!lastLat.isNaN()) {
val segmentKm = haversineKm(lastLat, lastLng, lat, lng)
totalDistanceKm += segmentKm
@@ -173,16 +449,10 @@ class LocationForegroundService : Service() {
lastLng = lng
lastTimestamp = now
val point = JSONObject().apply {
put("lat", lat)
put("lng", lng)
if (location.hasAltitude()) put("altitude", location.altitude)
if (location.hasSpeed()) put("speed", location.speed.toDouble())
put("timestamp", location.time)
}
pointBuffer.add(point)
updateNotification()
// Check distance-based TTS trigger
checkDistanceTrigger()
}
locationManager?.requestLocationUpdates(
@@ -195,9 +465,21 @@ class LocationForegroundService : Service() {
override fun onDestroy() {
tracking = false
paused = false
instance = null
locationListener?.let { locationManager?.removeUpdates(it) }
locationListener = null
locationManager = null
// Clean up TTS
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
ttsTimeHandler = null
ttsTimeRunnable = null
tts?.stop()
tts?.shutdown()
tts = null
ttsReady = false
super.onDestroy()
}