ai-chat-android-auto / commit
Add voice settings, TTS test, and API key field hiding
.../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"