fuzzyworld

ai-chat-android-auto / commit

Add voice settings, TTS test, and API key field hiding

9090d7c82e7603c06a018f07f267894f2599f169 · Pavel Safronov · 2026-05-14 20:22:19 -0700 · parents 2182d243a817

Add voice settings, TTS test, and API key field hiding

- Hide API key label + input once a key is saved; show only the Clear button
- New Voice Output section in SetupActivity: voice selector (local voices
  enumerated from TTS engine), speech rate seekbar (0.5×–2.0×), and a
  Test Voice button to audition settings on the phone before using the car
- Prefs: add voiceId and speechRate storage
- AiChatMediaService: apply saved voice and speech rate on TTS init

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../net/fuzzyworld/aichat/AiChatMediaService.kt    |   5 +-
 app/src/main/java/net/fuzzyworld/aichat/Prefs.kt   |  14 +++
 .../java/net/fuzzyworld/aichat/SetupActivity.kt    | 107 +++++++++++++++++++++
 app/src/main/res/layout/activity_setup.xml         |  92 ++++++++++++++++--
 4 files changed, 211 insertions(+), 7 deletions(-)

diff --git a/app/src/main/java/net/fuzzyworld/aichat/AiChatMediaService.kt b/app/src/main/java/net/fuzzyworld/aichat/AiChatMediaService.kt
index 7eaba2d..2f28db2 100644
--- a/app/src/main/java/net/fuzzyworld/aichat/AiChatMediaService.kt
+++ b/app/src/main/java/net/fuzzyworld/aichat/AiChatMediaService.kt
@@ -75,7 +75,10 @@ class AiChatMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
     override fun onInit(status: Int) {
         ttsReady = status == TextToSpeech.SUCCESS
         if (!ttsReady) return
-        tts.language = Locale.getDefault()
+        val savedVoiceId = Prefs.voiceId(this)
+        val savedVoice = if (savedVoiceId.isNotBlank()) tts.voices?.find { it.name == savedVoiceId } else null
+        if (savedVoice != null) tts.voice = savedVoice else tts.language = Locale.getDefault()
+        tts.setSpeechRate(Prefs.speechRate(this))
         tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
             override fun onStart(id: String?) {}
             override fun onDone(id: String?) {
diff --git a/app/src/main/java/net/fuzzyworld/aichat/Prefs.kt b/app/src/main/java/net/fuzzyworld/aichat/Prefs.kt
index e3fe60e..f60ff88 100644
--- a/app/src/main/java/net/fuzzyworld/aichat/Prefs.kt
+++ b/app/src/main/java/net/fuzzyworld/aichat/Prefs.kt
@@ -89,6 +89,20 @@ object Prefs {
         prefs(context).edit().putString("models_$providerId", arr.toString()).apply()
     }
 
+    // ── Voice output settings ──────────────────────────────────────────────────
+
+    fun voiceId(context: Context): String =
+        prefs(context).getString("voice_id", "") ?: ""
+
+    fun saveVoiceId(context: Context, id: String) =
+        prefs(context).edit().putString("voice_id", id).apply()
+
+    fun speechRate(context: Context): Float =
+        prefs(context).getFloat("speech_rate", 1.0f)
+
+    fun saveSpeechRate(context: Context, rate: Float) =
+        prefs(context).edit().putFloat("speech_rate", rate).apply()
+
     // ── Ready check (used by the media service) ────────────────────────────────
 
     fun isReady(context: Context): Boolean {
diff --git a/app/src/main/java/net/fuzzyworld/aichat/SetupActivity.kt b/app/src/main/java/net/fuzzyworld/aichat/SetupActivity.kt
index 0b408ac..d646feb 100644
--- a/app/src/main/java/net/fuzzyworld/aichat/SetupActivity.kt
+++ b/app/src/main/java/net/fuzzyworld/aichat/SetupActivity.kt
@@ -5,35 +5,47 @@ import android.content.pm.PackageManager
 import android.os.Bundle
 import android.os.Handler
 import android.os.Looper
+import android.speech.tts.TextToSpeech
+import android.speech.tts.Voice
 import android.view.View
 import android.widget.AdapterView
 import android.widget.ArrayAdapter
 import android.widget.Button
 import android.widget.EditText
 import android.widget.LinearLayout
+import android.widget.SeekBar
 import android.widget.Spinner
 import android.widget.TextView
 import android.widget.Toast
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.content.ContextCompat
+import kotlin.math.roundToInt
 
 class SetupActivity : AppCompatActivity() {
 
     private lateinit var providerSpinner: Spinner
     private lateinit var apiKeyGroup: LinearLayout
+    private lateinit var apiKeyLabel: TextView
     private lateinit var keyInput: EditText
     private lateinit var saveKeyBtn: Button
     private lateinit var clearKeyBtn: Button
     private lateinit var baseUrlGroup: LinearLayout
     private lateinit var baseUrlInput: EditText
     private lateinit var modelSpinner: Spinner
+    private lateinit var voiceSpinner: Spinner
+    private lateinit var speedSeekBar: SeekBar
+    private lateinit var speedLabel: TextView
     private lateinit var statusView: TextView
 
     private var selectedProvider: AiProvider = AnthropicProvider
     private var modelList: List<ModelInfo> = emptyList()
     private var initialized = false
 
+    private var setupTts: TextToSpeech? = null
+    private var setupTtsReady = false
+    private var voiceOptions: List<Voice?> = listOf(null) // null = system default
+
     private val mainHandler = Handler(Looper.getMainLooper())
 
     private val requestMic = registerForActivityResult(
@@ -46,16 +58,21 @@ class SetupActivity : AppCompatActivity() {
 
         providerSpinner = findViewById(R.id.providerSpinner)
         apiKeyGroup     = findViewById(R.id.apiKeyGroup)
+        apiKeyLabel     = findViewById(R.id.apiKeyLabel)
         keyInput        = findViewById(R.id.keyInput)
         saveKeyBtn      = findViewById(R.id.saveKeyBtn)
         clearKeyBtn     = findViewById(R.id.clearKeyBtn)
         baseUrlGroup    = findViewById(R.id.baseUrlGroup)
         baseUrlInput    = findViewById(R.id.baseUrlInput)
         modelSpinner    = findViewById(R.id.modelSpinner)
+        voiceSpinner    = findViewById(R.id.voiceSpinner)
+        speedSeekBar    = findViewById(R.id.speedSeekBar)
+        speedLabel      = findViewById(R.id.speedLabel)
         statusView      = findViewById(R.id.status)
 
         setupProviderSpinner()
         setupModelSpinner()
+        setupVoiceSection()
 
         saveKeyBtn.setOnClickListener {
             val key = keyInput.text.toString().trim()
@@ -92,10 +109,17 @@ class SetupActivity : AppCompatActivity() {
 
         if (!hasMic()) requestMic.launch(Manifest.permission.RECORD_AUDIO)
 
+        setupTts = TextToSpeech(this) { status -> onSetupTtsInit(status) }
+
         // defer so all setSelection() calls during init fire before listeners start saving
         mainHandler.post { initialized = true }
     }
 
+    override fun onDestroy() {
+        super.onDestroy()
+        setupTts?.shutdown()
+    }
+
     // ── Provider spinner ───────────────────────────────────────────────────────
 
     private fun setupProviderSpinner() {
@@ -160,6 +184,87 @@ class SetupActivity : AppCompatActivity() {
         initialized = prev
     }
 
+    // ── Voice section ──────────────────────────────────────────────────────────
+
+    private fun setupVoiceSection() {
+        voiceSpinner.adapter = ArrayAdapter(
+            this, android.R.layout.simple_spinner_item, listOf("Loading voices…")
+        ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
+
+        voiceSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+            override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) {
+                if (!initialized) return
+                Prefs.saveVoiceId(this@SetupActivity, voiceOptions.getOrNull(pos)?.name ?: "")
+            }
+            override fun onNothingSelected(parent: AdapterView<*>?) {}
+        }
+
+        val savedRate = Prefs.speechRate(this)
+        speedSeekBar.progress = speedToProgress(savedRate)
+        speedLabel.text = "%.1f×".format(savedRate)
+
+        speedSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+            override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) {
+                val speed = progressToSpeed(progress)
+                speedLabel.text = "%.1f×".format(speed)
+                if (fromUser) Prefs.saveSpeechRate(this@SetupActivity, speed)
+            }
+            override fun onStartTrackingTouch(bar: SeekBar?) {}
+            override fun onStopTrackingTouch(bar: SeekBar?) {}
+        })
+
+        findViewById<Button>(R.id.testVoiceBtn).setOnClickListener {
+            if (!setupTtsReady) {
+                Toast.makeText(this, "Voice engine not ready yet.", Toast.LENGTH_SHORT).show()
+                return@setOnClickListener
+            }
+            val voice = voiceOptions.getOrNull(voiceSpinner.selectedItemPosition)
+            setupTts?.apply {
+                setSpeechRate(progressToSpeed(speedSeekBar.progress))
+                if (voice != null) setVoice(voice)
+                speak(
+                    "The AI assistant is ready to help. Here is how it will sound in your car.",
+                    TextToSpeech.QUEUE_FLUSH, null, "test"
+                )
+            }
+        }
+    }
+
+    private fun onSetupTtsInit(status: Int) {
+        if (status != TextToSpeech.SUCCESS) return
+        setupTtsReady = true
+
+        val voices = (setupTts?.voices ?: emptySet())
+            .filter { !it.isNetworkConnectionRequired }
+            .sortedWith(compareByDescending<Voice> { it.quality }.thenBy { it.locale.displayName }.thenBy { it.name })
+
+        val localeCount = voices.groupBy { it.locale.displayName }.mapValues { it.value.size }
+        val displayNames = mutableListOf("System default")
+        voices.forEach { v ->
+            val base = v.locale.displayName
+            displayNames += if ((localeCount[base] ?: 1) > 1) "$base — ${v.name}" else base
+        }
+
+        voiceOptions = listOf(null) + voices
+
+        runOnUiThread {
+            val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayNames)
+            adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+
+            val prev = initialized
+            initialized = false
+            voiceSpinner.adapter = adapter
+            val savedId = Prefs.voiceId(this)
+            val idx = if (savedId.isBlank()) 0
+                      else voiceOptions.indexOfFirst { it?.name == savedId }.coerceAtLeast(0)
+            voiceSpinner.setSelection(idx)
+            initialized = prev
+        }
+    }
+
+    private fun progressToSpeed(progress: Int) = 0.5f + progress * 0.1f
+    private fun speedToProgress(speed: Float) = ((speed - 0.5f) / 0.1f).roundToInt().coerceIn(0, 15)
+
     // ── Model fetch ────────────────────────────────────────────────────────────
 
     private fun maybeAutoFetch() {
@@ -199,6 +304,8 @@ class SetupActivity : AppCompatActivity() {
         val hasKey = Prefs.hasApiKey(this, provider.id)
 
         apiKeyGroup.visibility = if (provider.requiresApiKey) View.VISIBLE else View.GONE
+        apiKeyLabel.visibility = if (!hasKey) View.VISIBLE else View.GONE
+        keyInput.visibility    = if (!hasKey) View.VISIBLE else View.GONE
         saveKeyBtn.visibility  = if (!hasKey) View.VISIBLE else View.GONE
         clearKeyBtn.visibility = if (hasKey) View.VISIBLE else View.GONE
 
diff --git a/app/src/main/res/layout/activity_setup.xml b/app/src/main/res/layout/activity_setup.xml
index 4e514d0..8fbf226 100644
--- a/app/src/main/res/layout/activity_setup.xml
+++ b/app/src/main/res/layout/activity_setup.xml
@@ -24,7 +24,8 @@
             android:textSize="14sp"
             android:layout_marginBottom="24dp" />
 
-        <!-- Provider -->
+        <!-- ── Provider ──────────────────────────────────────────────────── -->
+
         <TextView
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
@@ -39,7 +40,8 @@
             android:layout_height="wrap_content"
             android:layout_marginBottom="20dp" />
 
-        <!-- API key (hidden when provider doesn't require one) -->
+        <!-- ── API key ───────────────────────────────────────────────────── -->
+
         <LinearLayout
             android:id="@+id/apiKeyGroup"
             android:layout_width="match_parent"
@@ -48,6 +50,7 @@
             android:layout_marginBottom="20dp">
 
             <TextView
+                android:id="@+id/apiKeyLabel"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:text="API Key"
@@ -89,7 +92,8 @@
 
         </LinearLayout>
 
-        <!-- Base URL (shown for Ollama / Custom) -->
+        <!-- ── Base URL ──────────────────────────────────────────────────── -->
+
         <LinearLayout
             android:id="@+id/baseUrlGroup"
             android:layout_width="match_parent"
@@ -122,7 +126,8 @@
 
         </LinearLayout>
 
-        <!-- Model -->
+        <!-- ── Model ─────────────────────────────────────────────────────── -->
+
         <TextView
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
@@ -135,7 +140,7 @@
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:orientation="horizontal"
-            android:layout_marginBottom="24dp">
+            android:layout_marginBottom="28dp">
 
             <Spinner
                 android:id="@+id/modelSpinner"
@@ -152,7 +157,82 @@
 
         </LinearLayout>
 
-        <!-- Status -->
+        <!-- ── Voice Output ───────────────────────────────────────────────── -->
+
+        <View
+            android:layout_width="match_parent"
+            android:layout_height="1dp"
+            android:background="?android:attr/listDivider"
+            android:layout_marginBottom="20dp" />
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="Voice Output"
+            android:textSize="16sp"
+            android:textStyle="bold"
+            android:layout_marginBottom="16dp" />
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="Voice"
+            android:textSize="12sp"
+            android:textStyle="bold"
+            android:layout_marginBottom="4dp" />
+
+        <Spinner
+            android:id="@+id/voiceSpinner"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="16dp" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:layout_marginBottom="4dp">
+
+            <TextView
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:text="Speed"
+                android:textSize="12sp"
+                android:textStyle="bold" />
+
+            <TextView
+                android:id="@+id/speedLabel"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="1.0×"
+                android:textSize="12sp" />
+
+        </LinearLayout>
+
+        <SeekBar
+            android:id="@+id/speedSeekBar"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:max="15"
+            android:progress="5"
+            android:layout_marginBottom="12dp" />
+
+        <Button
+            android:id="@+id/testVoiceBtn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="Test Voice"
+            android:layout_marginBottom="28dp" />
+
+        <!-- ── Status ─────────────────────────────────────────────────────── -->
+
+        <View
+            android:layout_width="match_parent"
+            android:layout_height="1dp"
+            android:background="?android:attr/listDivider"
+            android:layout_marginBottom="20dp" />
+
         <TextView
             android:id="@+id/status"
             android:layout_width="match_parent"