fix: native Android TTS bridge in kiosk — bypass Web Speech API voice issues

On Android WebView, window.speechSynthesis.getVoices() often returns empty
because the Web Speech API cannot enumerate the device's TTS voices.
This caused the kiosk to show 'nessuna voce offline è supportata'.

Changes:
- KioskActivity.kt: initialise Android TextToSpeech engine on startup;
  expose speak(text, rate, pitch), stopSpeech() and isTtsReady() via
  the existing _kioskBridge JavascriptInterface; release TTS in onDestroy.
- app.js (_speakBrowser): when _kioskBridge.speak is available, delegate
  to it instead of using speechSynthesis — works even without offline voice
  packs installed.
- app.js (_initBrowserTtsVoices): show 'Voce nativa Android (kiosk)'
  in the voice dropdown when running inside the kiosk WebView.
- app.js (testTTS): use the bridge path when testing TTS inside the kiosk.
This commit is contained in:
dadaloop82
2026-04-27 11:52:30 +00:00
parent 8b5985dc80
commit 95389ebe87
2 changed files with 86 additions and 7 deletions
+34 -5
View File
@@ -9368,6 +9368,13 @@ function _initBrowserTtsVoices(selectedVoice) {
const sel = document.getElementById('setting-tts-voice');
if (!sel) return;
// Inside the EverShelf Kiosk Android app the native TTS bridge handles
// speech — no Web Speech API voice list needed.
if (typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function') {
sel.innerHTML = '<option value="">— Voce nativa Android (kiosk) —</option>';
return;
}
if (!window.speechSynthesis) {
sel.innerHTML = '<option value="">— Voce non supportata dal browser —</option>';
return;
@@ -9417,19 +9424,31 @@ function _initBrowserTtsVoices(selectedVoice) {
}, 200);
}
/** Speak text using the browser Web Speech API (offline). */
/** Speak text using the browser Web Speech API (offline).
* When running inside the EverShelf Kiosk Android app the native TTS bridge
* is preferred it bypasses Web Speech API voice limitations on Android. */
function _speakBrowser(text) {
const s = getSettings();
const rate = parseFloat(s.tts_rate) || 1;
const pitch = parseFloat(s.tts_pitch) || 1;
// ── Native Android TTS bridge (kiosk WebView) ──────────────────────
if (typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function') {
try { _kioskBridge.speak(text, rate, pitch); } catch(_e) { /* silent */ }
return;
}
// ── Web Speech API (desktop / mobile browser) ──────────────────────
if (!window.speechSynthesis) return;
window.speechSynthesis.cancel();
const s = getSettings();
const utt = new SpeechSynthesisUtterance(text);
utt.rate = parseFloat(s.tts_rate) || 1;
utt.pitch = parseFloat(s.tts_pitch) || 1;
utt.rate = rate;
utt.pitch = pitch;
const voices = window.speechSynthesis.getVoices();
const preferred = voices.find(v => v.name === s.tts_voice);
if (preferred) {
utt.voice = preferred;
utt.lang = preferred.lang;
utt.lang = preferred.lang;
} else {
utt.lang = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-US' : 'it-IT';
}
@@ -9445,6 +9464,16 @@ async function testTTS() {
}
const engine = document.getElementById('setting-tts-engine')?.value || 'browser';
if (engine === 'browser') {
// Kiosk native TTS bridge takes priority over Web Speech API
if (typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function') {
const s = getSettings();
s.tts_rate = parseFloat(document.getElementById('setting-tts-rate')?.value) || 1;
s.tts_pitch = parseFloat(document.getElementById('setting-tts-pitch')?.value) || 1;
saveSettingsToStorage(s);
_speakBrowser('Test vocale EverShelf. La sintesi vocale funziona correttamente.');
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status success'; statusEl.textContent = '✅ Riproduzione in corso — controlla l\'audio del dispositivo.'; }
return;
}
if (!window.speechSynthesis) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Web Speech API non supportata da questo browser.'; }
return;
@@ -14,11 +14,13 @@ import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.tts.TextToSpeech
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
@@ -39,6 +41,7 @@ import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import org.json.JSONObject
import java.net.URL
import java.util.Locale
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
@@ -49,6 +52,11 @@ class KioskActivity : AppCompatActivity() {
private lateinit var prefs: SharedPreferences
private var currentStep = 1
// Native TTS engine (Android) — used by the JS bridge so the WebView
// doesn't depend on Web Speech API voices being installed.
private var tts: TextToSpeech? = null
private var ttsReady = false
// Views
private lateinit var splashContainer: LinearLayout
private lateinit var wizardContainer: ScrollView
@@ -98,6 +106,19 @@ class KioskActivity : AppCompatActivity() {
enableKioskLock()
requestAllPermissions()
// Initialise native TTS engine so the JS bridge works even when
// Web Speech API voices are unavailable in the Android WebView.
tts = TextToSpeech(this) { status ->
if (status == TextToSpeech.SUCCESS) {
val it = tts?.setLanguage(Locale.ITALIAN)
if (it == TextToSpeech.LANG_MISSING_DATA || it == TextToSpeech.LANG_NOT_SUPPORTED) {
// Italian data missing — fall back to device default
tts?.language = Locale.getDefault()
}
ttsReady = true
}
}
// Show splash then proceed
Handler(Looper.getMainLooper()).postDelayed({
splashContainer.visibility = View.GONE
@@ -506,7 +527,7 @@ class KioskActivity : AppCompatActivity() {
// Add JS interface ONCE before loading
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
@JavascriptInterface
fun exit() {
runOnUiThread {
disableKioskLock()
@@ -514,13 +535,35 @@ class KioskActivity : AppCompatActivity() {
finishAffinity()
}
}
@android.webkit.JavascriptInterface
@JavascriptInterface
fun hardReload() {
runOnUiThread {
webView.clearCache(true)
webView.reload()
}
}
/**
* Speak [text] via Android native TTS.
* Called by app.js when running inside the kiosk WebView so that
* speech synthesis works even without Web Speech API offline voices.
* [rate] and [pitch] are floats (default 1.0).
*/
@JavascriptInterface
fun speak(text: String, rate: Float, pitch: Float) {
val engine = tts ?: return
if (!ttsReady) return
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
engine.setPitch(pitch.coerceIn(0.1f, 4f))
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts")
}
/** Cancel any ongoing speech. */
@JavascriptInterface
fun stopSpeech() {
tts?.stop()
}
/** Returns "true" when the TTS engine is ready. */
@JavascriptInterface
fun isTtsReady(): String = if (ttsReady) "true" else "false"
}, "_kioskBridge")
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
@@ -729,6 +772,13 @@ class KioskActivity : AppCompatActivity() {
}
}
override fun onDestroy() {
tts?.stop()
tts?.shutdown()
tts = null
super.onDestroy()
}
override fun onBackPressed() {
if (webView.visibility == View.VISIBLE && webView.canGoBack()) {
webView.goBack()