Skip to content

fix: TTS playback, screen wake lock, and TTS phrase caching#9

Merged
nbucic merged 4 commits intomasterfrom
fix/tts-wakelock-caching
Apr 16, 2026
Merged

fix: TTS playback, screen wake lock, and TTS phrase caching#9
nbucic merged 4 commits intomasterfrom
fix/tts-wakelock-caching

Conversation

@nbucic
Copy link
Copy Markdown
Owner

@nbucic nbucic commented Apr 13, 2026

Summary

  • TTS silent on Androidwindow.NativePhp.tts.speak was called but that object never existed in the codebase; browser speechSynthesis fallback also failed silently because getVoices() returns [] synchronously and the code never waited for the voiceschanged event.
  • Screen never stays awakekeepScreenOn setting was stored in the DB (default true) but never consumed: TimerScreen.php didn't expose it as a Livewire property, Settings.php didn't include it in settingsLoaded, and app.js had zero wake lock code.
  • TTS caching — native Kotlin bridge (see below) pre-synthesizes all known phrases to WAV files in the app cache directory on first launch; replays them via MediaPlayer with zero latency on subsequent runs.

What was changed (committed)

File Change
resources/js/audio.js speak() now uses window.AndroidTTS?.speak (Kotlin bridge); browser fallback waits for voiceschanged event before calling speechSynthesis.speak()
resources/js/app.js Added keepScreenOn property + navigator.wakeLock.request/release tied to timer state; added settingsLoaded event listener to sync settings changes from Settings screen
app/Livewire/TimerScreen.php Added public bool $keepScreenOn loaded from settings in mount()
app/Livewire/Settings.php Added keepScreenOn to settingsLoaded dispatch

Kotlin changes required in nativephp/android/ (gitignored)

The /nativephp directory is gitignored so these three changes must be applied manually after pulling this branch.

1. Create nativephp/android/app/src/main/java/com/nativephp/mobile/bridge/TTSBridge.kt

package com.nativephp.mobile.bridge

import android.content.Context
import android.media.MediaPlayer
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import android.webkit.JavascriptInterface
import java.io.File
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap

class TTSBridge(private val context: Context) {

    private companion object {
        const val TAG = "TTSBridge"
        const val CACHE_DAYS = 3L

        val KNOWN_PHRASES = listOf(
            "Done", "Go", "Next", "Get ready",
            "3", "2", "1",
            "Rest", "Work", "Prepare",
        )
    }

    private var tts: TextToSpeech? = null
    private val players = ConcurrentHashMap<String, MediaPlayer>()
    private var engineReady = false

    init {
        tts = TextToSpeech(context) { status ->
            if (status == TextToSpeech.SUCCESS) {
                tts?.language = Locale.US
                tts?.setPitch(0.9f)
                tts?.setSpeechRate(0.85f)
                engineReady = true
                setupUtteranceListener()
                prebuildCache()
                Log.d(TAG, "TTS engine ready")
            } else {
                Log.e(TAG, "TTS engine init failed with status $status")
            }
        }
    }

    private fun cacheFile(phrase: String): File {
        val safeName = phrase.lowercase().replace(Regex("[^a-z0-9]"), "_")
        return File(context.cacheDir, "tts_$safeName.wav")
    }

    private fun isCacheStale(file: File): Boolean {
        if (!file.exists()) return true
        val cutoffMs = System.currentTimeMillis() - CACHE_DAYS * 86_400_000L
        return file.lastModified() < cutoffMs
    }

    private fun setupUtteranceListener() {
        tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
            override fun onDone(utteranceId: String) {
                val file = cacheFile(utteranceId)
                if (file.exists() && file.length() > 0) {
                    try {
                        val player = MediaPlayer().apply {
                            setDataSource(file.absolutePath)
                            prepare()
                        }
                        players[utteranceId] = player
                    } catch (e: Exception) {
                        Log.e(TAG, "Failed to load MediaPlayer for $utteranceId: ${e.message}")
                    }
                }
            }
            override fun onError(utteranceId: String) {}
            override fun onStart(utteranceId: String) {}
        })
    }

    private fun prebuildCache() {
        KNOWN_PHRASES.forEach { phrase ->
            val file = cacheFile(phrase)
            if (isCacheStale(file)) {
                val params = Bundle()
                tts?.synthesizeToFile(phrase, params, file, phrase)
            } else {
                try {
                    players[phrase] = MediaPlayer().apply {
                        setDataSource(file.absolutePath)
                        prepare()
                    }
                } catch (e: Exception) {
                    file.delete()
                    val params = Bundle()
                    tts?.synthesizeToFile(phrase, params, file, phrase)
                }
            }
        }
    }

    @JavascriptInterface
    fun speak(text: String) {
        val player = players[text]
        if (player != null) {
            try {
                player.seekTo(0)
                player.start()
                return
            } catch (e: Exception) {
                players.remove(text)
            }
        }
        if (engineReady) {
            tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, null)
        }
    }

    fun shutdown() {
        players.values.forEach { try { it.release() } catch (_: Exception) {} }
        players.clear()
        tts?.shutdown()
        tts = null
        engineReady = false
    }
}

2. Edit WebViewManager.kt — add TTSBridge import + field + registration + shutdown

// Add import at top:
import com.nativephp.mobile.bridge.TTSBridge

// Add field alongside other private vals:
private val ttsBridge = TTSBridge(context)

// In setupJavaScriptInterfaces():
private fun setupJavaScriptInterfaces() {
    webView.addJavascriptInterface(JSBridge(phpBridge, TAG), "AndroidPOST")
    webView.addJavascriptInterface(ttsBridge, "AndroidTTS")   // ← add this line
}

// Add new public method:
fun shutdown() {
    ttsBridge.shutdown()
}

3. Edit MainActivity.kt — call webViewManager.shutdown() in onDestroy()

if (::webViewManager.isInitialized) {
    val chromeClient = webView.webChromeClient
    if (chromeClient is WebChromeClient) {
        chromeClient.onHideCustomView()
    }
    webViewManager.shutdown()   // ← add this line
}

Test plan

  • Start a timer in voice mode — each phase transition should speak (Done / Go / Next / Get ready)
  • Verify second+ run plays cached phrases with no perceptible delay
  • Start a timer — screen should stay on throughout
  • Toggle "Keep screen on" OFF in Settings, start timer — screen should dim/lock normally
  • Toggle it back ON — screen stays on again
  • Confirm wake lock releases when timer finishes or is stopped

🤖 Generated with Claude Code

nbucic and others added 4 commits April 13, 2026 18:02
**TTS not playing (audio.js + TTSBridge.kt)**
- audio.js was checking window.NativePhp?.tts?.speak which never existed
  in the Android codebase — no Kotlin bridge was ever installed for it.
- Browser speechSynthesis fallback also silently failed because
  getVoices() returns [] synchronously; code never waited for the
  voiceschanged event, so no voice was selected.
- Fix JS side: check window.AndroidTTS?.speak (native Kotlin bridge,
  see PR description for Kotlin patch) and fix the browser fallback to
  wait for voiceschanged before calling speak().

**Screen wake lock never acquired (app.js + TimerScreen.php + Settings.php)**
- keepScreenOn setting was saved to DB (default true) but never consumed:
  TimerScreen.php did not expose it as a Livewire property, Settings.php
  did not include it in the settingsLoaded event, and app.js had no
  wake lock code whatsoever.
- Fix: expose keepScreenOn on TimerScreen, pass it through settingsLoaded,
  and use navigator.wakeLock.request('screen') in timerAudio Alpine
  component tied to timer state transitions (acquire on active states,
  release on idle/complete). Re-acquires automatically if OS releases it.

**TTS caching (TTSBridge.kt)**
- Once the native Kotlin bridge is applied, TTSBridge pre-synthesizes all
  known phrases (Done, Go, Next, Get ready, 3/2/1 …) to WAV files in the
  app cache directory on first launch and reloads them via MediaPlayer.
- Cache TTL is 3 days; stale/missing files are re-synthesized automatically.
- Known phrases play instantly with zero TTS engine startup latency.
  Dynamic labels (e.g. custom countdown text) fall back to live TTS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On Android WebView, speechSynthesis.voiceschanged may never fire, which
caused the speak() fallback path to register a listener that never ran --
effectively silencing all TTS when window.AndroidTTS is not yet available
(i.e. before the APK is rebuilt with TTSBridge.kt).

Fix: speak immediately using whatever voices are already loaded; the
default voice is used if no en-* voice is found, which is correct
behaviour on Android where voice selection is handled by the TTS engine.

Also replace smart-quote/em-dash characters in comments with plain ASCII
to prevent future string-match failures in automated tooling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
**Demo HIIT program (seeder)**
- composer.json: add Database\\Seeders\\ to production autoload.psr-4
  so the seeder is available in the embedded NativePHP runtime.
- database/seeders/DatabaseSeeder.php: new seeder that creates the
  demo HIIT program (Warmup 10s / Sprint 8sx3 / Stretch 8s) when the
  programs table is empty. Idempotent via Program::exists() guard.
  Calls Setting::current() first so the settings row exists before
  Program construction touches it.
- AppServiceProvider::boot(): call DatabaseSeeder wrapped in try/catch
  so the inevitable "table does not exist" error during `artisan migrate`
  itself is swallowed gracefully and succeeds on the next boot.

**TTS diagnostic logging**
JS (app.js + audio.js):
- timerAudio init(): logs soundMode, keepScreenOn, and whether
  window.AndroidTTS is already present at page-load time.
- playBeep listener: logs voiceText() output before calling speak()
  so an empty countdownLabel is visible before the call.
- speak(): logs which path is taken (AndroidTTS bridge / speechSynthesis
  / neither), voice count + selection, and attaches utt.onerror so
  SpeechSynthesisUtterance failures surface in the console instead of
  silently disappearing.

Kotlin (TTSBridge.kt + WebViewManager.kt -- gitignored):
- WebViewManager: log after AndroidTTS bridge registration.
- TTSBridge.speak(): entry log with text always fires (was missing).
- TTSBridge.speak(): explicit cache-miss log before speakLive().
- TTSBridge.speakLive(): tts null-check with error log.
- TTSBridge.prebuildCache(): log phrase count at start.
- All Kotlin log messages prefixed [TTS] for easy Logcat filtering:
  adb logcat -s PHPMonitor:D PHPMonitor-Console:D TTSBridge:D

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a path-repository package that wraps Android TTS (and a Web Speech
API fallback) into a NativePHP-style bridge (Kotlin + PHP + JS).
Registers it via a new NativeServiceProvider and integrates it into
Settings with an updateAndTest() action for in-app voice testing.
Also bumps nativephp/mobile to 3.2.3 and pins axios to 1.15.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nbucic nbucic merged commit 084a385 into master Apr 16, 2026
1 check failed
@nbucic nbucic deleted the fix/tts-wakelock-caching branch April 16, 2026 08:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant