From d2eb56ab1614301caa256530b939960d8b2caad9 Mon Sep 17 00:00:00 2001 From: Christopher Jones Date: Thu, 16 Jul 2026 16:37:36 -0400 Subject: [PATCH] Add Settings dialog and DataStore --- .gitignore | 1 + .idea/deploymentTargetSelector.xml | 7 + app/build.gradle.kts | 2 + .../whisperclient2/MainActivity.kt | 309 +++++++++++++++--- .../whisperclient2/SettingsManager.kt | 118 +++++++ gradle/libs.versions.toml | 6 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 7 files changed, 399 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/objectbrokers/whisperclient2/SettingsManager.kt diff --git a/.gitignore b/.gitignore index 14df005..05510f4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ google-services.json *.iws .idea/workspace.xml .idea/tasks.xml +.idea/planningMode.xml .idea/vcs.xml .idea/dictionaries .idea/libraries diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index ca16a99..6a4b107 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,13 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa74583..fd02884 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,11 +42,13 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.datastore.preferences) implementation("com.squareup.okhttp3:okhttp:5.4.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") diff --git a/app/src/main/java/com/objectbrokers/whisperclient2/MainActivity.kt b/app/src/main/java/com/objectbrokers/whisperclient2/MainActivity.kt index 6eb9ed5..1c3377f 100644 --- a/app/src/main/java/com/objectbrokers/whisperclient2/MainActivity.kt +++ b/app/src/main/java/com/objectbrokers/whisperclient2/MainActivity.kt @@ -13,29 +13,47 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first -//import androidx.privacysandbox.tools.core.generator.build import kotlinx.coroutines.Job import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -53,6 +71,7 @@ import org.json.JSONObject import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.UUID +import java.util.concurrent.TimeUnit import kotlin.math.roundToInt const val TAG = "SpeechClient" @@ -69,7 +88,11 @@ class MainActivity : ComponentActivity() { private var buffer: ByteArray? = null private var isRecording by mutableStateOf(false) private var transcriptText by mutableStateOf("Transcription will appear here...") - private val client = OkHttpClient() + private var connectionStatus by mutableStateOf("Disconnected") + private lateinit var settingsManager: SettingsManager + private val client = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .build() private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> @@ -82,21 +105,52 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + settingsManager = SettingsManager(this) enableEdgeToEdge() setContent { + val settings by settingsManager.settingsFlow.collectAsState( + initial = HandshakeSettings( + SettingsManager.DEFAULT_HOST, SettingsManager.DEFAULT_PORT, + SettingsManager.DEFAULT_TASK, SettingsManager.DEFAULT_MODEL, + SettingsManager.DEFAULT_LANGUAGE, SettingsManager.DEFAULT_USE_VAD, + SettingsManager.DEFAULT_AUDIO_FORMAT, SettingsManager.DEFAULT_SEND_LAST_N_SEGMENTS, + SettingsManager.DEFAULT_NO_SPEECH_THRESH, SettingsManager.DEFAULT_CLIP_AUDIO, + SettingsManager.DEFAULT_SAME_OUTPUT_THRESHOLD, SettingsManager.DEFAULT_ENABLE_TRANSLATION, + SettingsManager.DEFAULT_TARGET_LANGUAGE, null, + SettingsManager.DEFAULT_ENABLE_DIARIZATION, SettingsManager.DEFAULT_MAX_SPEAKERS, + SettingsManager.DEFAULT_WORD_TIMESTAMPS + ) + ) + var showSettings by remember { mutableStateOf(false) } + WhisperClient2Theme { - WhisperApp(transcriptText = transcriptText, - isRecording = isRecording) { - // this block IS the onToggleRecording lambda - if (isRecording) - { - stopRecording() - isRecording = false - } - else - { - startRecording(); isRecording = true + WhisperApp( + transcriptText = transcriptText, + isRecording = isRecording, + connectionStatus = connectionStatus, + onOpenSettings = { showSettings = true }, + onToggleRecording = { + if (isRecording) { + stopRecording() + isRecording = false + } else { + startRecording() + isRecording = true + } } + ) + + if (showSettings) { + SettingsDialog( + initialSettings = settings, + onDismiss = { showSettings = false }, + onSave = { newSettings -> + lifecycleScope.launch { + settingsManager.saveSettings(newSettings) + showSettings = false + } + } + ) } } } @@ -167,37 +221,36 @@ class MainActivity : ComponentActivity() { } } if (audioRecordOK) { + withContext(Dispatchers.Main) { + connectionStatus = "Connecting" + } // Create the WebSocket instance + val settings = settingsManager.settingsFlow.first() val request = Request.Builder() - .url("ws://100.81.165.11:9097") + .url("ws://${settings.host}:${settings.port}") .build() val listener = object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { // get a new client UUID (unique session ID) for each connection clientUid = UUID.randomUUID() // Build the handshake JSON using Android's built-in JSONObject. - // Every field the server expects is here; safe to hardcode the ones - // you don't need to expose in the UI. val handshake = JSONObject().apply { put("uid", clientUid.toString()) - put("task", "transcribe") - put("model", "OpenVINO/whisper-medium-fp16-ov") - put("language", "en") - put("use_vad", false) - put("audio_format", "int16") - put("send_last_n_segments", 10) - put("no_speech_thresh", 0.45) - put("clip_audio", false) - put("same_output_threshold", 10) - put("enable_translation", false) - put( - "target_language", - "fr" - ) // server requires this key even if translation is off - put("hotwords", JSONObject.NULL) - put("enable_diarization", false) - put("max_speakers", 10) - put("word_timestamps", false) + put("task", settings.task) + put("model", settings.model) + put("language", settings.language) + put("use_vad", settings.useVad) + put("audio_format", settings.audioFormat) + put("send_last_n_segments", settings.sendLastNSegments) + put("no_speech_thresh", settings.noSpeechThresh) + put("clip_audio", settings.clipAudio) + put("same_output_threshold", settings.sameOutputThreshold) + put("enable_translation", settings.enableTranslation) + put("target_language", settings.targetLanguage) + put("hotwords", settings.hotwords ?: JSONObject.NULL) + put("enable_diarization", settings.enableDiarization) + put("max_speakers", settings.maxSpeakers) + put("word_timestamps", settings.wordTimestamps) } // send() with a String argument sends a WebSocket text frame webSocket.send(handshake.toString()) @@ -223,12 +276,16 @@ class MainActivity : ComponentActivity() { "WAIT" -> { val minutes = message.getDouble("message").roundToInt() Log.i(TAG, "Server full. Estimated wait: $minutes minutes") - // Optionally show this in the UI + runOnUiThread { + connectionStatus = "Wait ($minutes min)" + } } "ERROR" -> { Log.e(TAG, "Server error: ${message.getString("message")}") - // Stop recording, show error in UI + runOnUiThread { + connectionStatus = "Error" + } } "WARNING" -> { @@ -249,7 +306,7 @@ class MainActivity : ComponentActivity() { message.getString("backend") // e.g. "faster_whisper" Log.i(TAG, "Server ready, backend: $backend") runOnUiThread { -// statusTextView.text = "Connected" + connectionStatus = "Server Ready" } startAudioCapture() // <-- safe to start sending audio now } @@ -289,12 +346,33 @@ class MainActivity : ComponentActivity() { } } + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + Log.e(TAG, "WebSocket closing: $code / $reason") + runOnUiThread { + if (code != 1000) { + connectionStatus = "Error" + } + } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.i(TAG, "WebSocket closed: $code / $reason") + runOnUiThread { + if (connectionStatus != "Error") { + connectionStatus = "Disconnected" + } + } + } + override fun onFailure( webSocket: WebSocket, t: Throwable, response: Response? ) { Log.e(TAG, "WebSocket connection failed: ${t.message}", t) + runOnUiThread { + connectionStatus = "Error" + } } } @@ -306,6 +384,7 @@ class MainActivity : ComponentActivity() { fun stopRecording() { + connectionStatus = "Disconnected" stopAudioCapture() recordingJob?.cancel() // the ?. is Kotlin null-safety — no NPE if job is null webSocket?.close(1000, "Done") @@ -358,14 +437,27 @@ fun WhisperApp(text: String) { // ... inside your scrollable column ... Text( text = text, // It is now tied to the 'transcriptText' variable style = MaterialTheme.typography.bodyLarge ) } */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun WhisperApp( transcriptText: String, isRecording: Boolean = false, + connectionStatus: String = "Disconnected", + onOpenSettings: () -> Unit = {}, onToggleRecording: () -> Unit = {} ) { Scaffold( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Whisper Client") }, + actions = { + IconButton(onClick = onOpenSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + } + ) + } ) { innerPadding -> Column( modifier = Modifier @@ -393,19 +485,148 @@ fun WhisperApp( ) } - Button( - onClick = onToggleRecording, - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - content = { Text(if (isRecording) "Stop Transcription" else "Start Transcription") } - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( + onClick = onToggleRecording, + modifier = Modifier.weight(1f), + content = { Text(if (isRecording) "Stop Transcription" else "Start Transcription") } + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = connectionStatus, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = when (connectionStatus) { + "Server Ready" -> Color(0xFF4CAF50) // Green + "Error" -> MaterialTheme.colorScheme.error + "Connecting" -> Color(0xFFFFA000) // Amber + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } } } } +@Composable +fun SettingsDialog( + initialSettings: HandshakeSettings, + onDismiss: () -> Unit, + onSave: (HandshakeSettings) -> Unit +) { + var host by remember { mutableStateOf(initialSettings.host) } + var port by remember { mutableStateOf(initialSettings.port) } + var task by remember { mutableStateOf(initialSettings.task) } + var model by remember { mutableStateOf(initialSettings.model) } + var language by remember { mutableStateOf(initialSettings.language) } + var useVad by remember { mutableStateOf(initialSettings.useVad) } + var audioFormat by remember { mutableStateOf(initialSettings.audioFormat) } + var sendLastNSegments by remember { mutableStateOf(initialSettings.sendLastNSegments.toString()) } + var noSpeechThresh by remember { mutableStateOf(initialSettings.noSpeechThresh.toString()) } + var clipAudio by remember { mutableStateOf(initialSettings.clipAudio) } + var sameOutputThreshold by remember { mutableStateOf(initialSettings.sameOutputThreshold.toString()) } + var enableTranslation by remember { mutableStateOf(initialSettings.enableTranslation) } + var targetLanguage by remember { mutableStateOf(initialSettings.targetLanguage) } + var hotwords by remember { mutableStateOf(initialSettings.hotwords ?: "") } + var enableDiarization by remember { mutableStateOf(initialSettings.enableDiarization) } + var maxSpeakers by remember { mutableStateOf(initialSettings.maxSpeakers.toString()) } + var wordTimestamps by remember { mutableStateOf(initialSettings.wordTimestamps) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Server & Handshake Settings") }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text("Network", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + OutlinedTextField(value = host, onValueChange = { host = it }, label = { Text("Host / IP") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = port, onValueChange = { port = it }, label = { Text("Port") }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + Text("Whisper Configuration", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + OutlinedTextField(value = task, onValueChange = { task = it }, label = { Text("Task") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = model, onValueChange = { model = it }, label = { Text("Model") }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = language, onValueChange = { language = it }, label = { Text("Language") }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = audioFormat, onValueChange = { audioFormat = it }, label = { Text("Audio Format") }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = hotwords, onValueChange = { hotwords = it }, label = { Text("Hotwords (Optional)") }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) { + Text("Use VAD", modifier = Modifier.weight(1f)) + Switch(checked = useVad, onCheckedChange = { useVad = it }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) { + Text("Clip Audio", modifier = Modifier.weight(1f)) + Switch(checked = clipAudio, onCheckedChange = { clipAudio = it }) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) { + Text("Word Timestamps", modifier = Modifier.weight(1f)) + Switch(checked = wordTimestamps, onCheckedChange = { wordTimestamps = it }) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + Text("Thresholds & Segments", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + OutlinedTextField(value = sendLastNSegments, onValueChange = { sendLastNSegments = it }, label = { Text("Send Last N Segments") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = noSpeechThresh, onValueChange = { noSpeechThresh = it }, label = { Text("No Speech Threshold") }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = sameOutputThreshold, onValueChange = { sameOutputThreshold = it }, label = { Text("Same Output Threshold") }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + Text("Translation", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Enable Translation", modifier = Modifier.weight(1f)) + Switch(checked = enableTranslation, onCheckedChange = { enableTranslation = it }) + } + OutlinedTextField(value = targetLanguage, onValueChange = { targetLanguage = it }, label = { Text("Target Language") }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + Text("Diarization", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Enable Diarization", modifier = Modifier.weight(1f)) + Switch(checked = enableDiarization, onCheckedChange = { enableDiarization = it }) + } + OutlinedTextField(value = maxSpeakers, onValueChange = { maxSpeakers = it }, label = { Text("Max Speakers") }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + } + }, + confirmButton = { + TextButton(onClick = { + onSave( + HandshakeSettings( + host = host, port = port, task = task, model = model, language = language, + useVad = useVad, audioFormat = audioFormat, + sendLastNSegments = sendLastNSegments.toIntOrNull() ?: SettingsManager.DEFAULT_SEND_LAST_N_SEGMENTS, + noSpeechThresh = noSpeechThresh.toDoubleOrNull() ?: SettingsManager.DEFAULT_NO_SPEECH_THRESH, + clipAudio = clipAudio, + sameOutputThreshold = sameOutputThreshold.toIntOrNull() ?: SettingsManager.DEFAULT_SAME_OUTPUT_THRESHOLD, + enableTranslation = enableTranslation, targetLanguage = targetLanguage, + hotwords = hotwords.takeIf { it.isNotBlank() }, + enableDiarization = enableDiarization, + maxSpeakers = maxSpeakers.toIntOrNull() ?: SettingsManager.DEFAULT_MAX_SPEAKERS, + wordTimestamps = wordTimestamps + ) + ) + }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + @Preview(showBackground = true) @Composable fun WhisperAppPreview() { WhisperClient2Theme { - WhisperApp("Transcription will appear here...") + WhisperApp( + transcriptText = "Transcription will appear here...", + connectionStatus = "Disconnected" + ) } } diff --git a/app/src/main/java/com/objectbrokers/whisperclient2/SettingsManager.kt b/app/src/main/java/com/objectbrokers/whisperclient2/SettingsManager.kt new file mode 100644 index 0000000..13fc85a --- /dev/null +++ b/app/src/main/java/com/objectbrokers/whisperclient2/SettingsManager.kt @@ -0,0 +1,118 @@ +package com.objectbrokers.whisperclient2 + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +data class HandshakeSettings( + val host: String, + val port: String, + val task: String, + val model: String, + val language: String, + val useVad: Boolean, + val audioFormat: String, + val sendLastNSegments: Int, + val noSpeechThresh: Double, + val clipAudio: Boolean, + val sameOutputThreshold: Int, + val enableTranslation: Boolean, + val targetLanguage: String, + val hotwords: String?, + val enableDiarization: Boolean, + val maxSpeakers: Int, + val wordTimestamps: Boolean +) + +class SettingsManager(private val context: Context) { + + companion object { + val HOST_KEY = stringPreferencesKey("websocket_host") + val PORT_KEY = stringPreferencesKey("websocket_port") + val TASK_KEY = stringPreferencesKey("task") + val MODEL_KEY = stringPreferencesKey("model") + val LANGUAGE_KEY = stringPreferencesKey("language") + val USE_VAD_KEY = booleanPreferencesKey("use_vad") + val AUDIO_FORMAT_KEY = stringPreferencesKey("audio_format") + val SEND_LAST_N_SEGMENTS_KEY = intPreferencesKey("send_last_n_segments") + val NO_SPEECH_THRESH_KEY = doublePreferencesKey("no_speech_thresh") + val CLIP_AUDIO_KEY = booleanPreferencesKey("clip_audio") + val SAME_OUTPUT_THRESHOLD_KEY = intPreferencesKey("same_output_threshold") + val ENABLE_TRANSLATION_KEY = booleanPreferencesKey("enable_translation") + val TARGET_LANGUAGE_KEY = stringPreferencesKey("target_language") + val HOTWORDS_KEY = stringPreferencesKey("hotwords") + val ENABLE_DIARIZATION_KEY = booleanPreferencesKey("enable_diarization") + val MAX_SPEAKERS_KEY = intPreferencesKey("max_speakers") + val WORD_TIMESTAMPS_KEY = booleanPreferencesKey("word_timestamps") + + const val DEFAULT_HOST = "100.81.165.11" + const val DEFAULT_PORT = "9097" + const val DEFAULT_TASK = "transcribe" + const val DEFAULT_MODEL = "OpenVINO/whisper-medium-fp16-ov" + const val DEFAULT_LANGUAGE = "en" + const val DEFAULT_USE_VAD = false + const val DEFAULT_AUDIO_FORMAT = "int16" + const val DEFAULT_SEND_LAST_N_SEGMENTS = 10 + const val DEFAULT_NO_SPEECH_THRESH = 0.45 + const val DEFAULT_CLIP_AUDIO = false + const val DEFAULT_SAME_OUTPUT_THRESHOLD = 10 + const val DEFAULT_ENABLE_TRANSLATION = false + const val DEFAULT_TARGET_LANGUAGE = "fr" + const val DEFAULT_ENABLE_DIARIZATION = false + const val DEFAULT_MAX_SPEAKERS = 10 + const val DEFAULT_WORD_TIMESTAMPS = false + } + + val settingsFlow: Flow = context.dataStore.data.map { preferences -> + HandshakeSettings( + host = preferences[HOST_KEY] ?: DEFAULT_HOST, + port = preferences[PORT_KEY] ?: DEFAULT_PORT, + task = preferences[TASK_KEY] ?: DEFAULT_TASK, + model = preferences[MODEL_KEY] ?: DEFAULT_MODEL, + language = preferences[LANGUAGE_KEY] ?: DEFAULT_LANGUAGE, + useVad = preferences[USE_VAD_KEY] ?: DEFAULT_USE_VAD, + audioFormat = preferences[AUDIO_FORMAT_KEY] ?: DEFAULT_AUDIO_FORMAT, + sendLastNSegments = preferences[SEND_LAST_N_SEGMENTS_KEY] ?: DEFAULT_SEND_LAST_N_SEGMENTS, + noSpeechThresh = preferences[NO_SPEECH_THRESH_KEY] ?: DEFAULT_NO_SPEECH_THRESH, + clipAudio = preferences[CLIP_AUDIO_KEY] ?: DEFAULT_CLIP_AUDIO, + sameOutputThreshold = preferences[SAME_OUTPUT_THRESHOLD_KEY] ?: DEFAULT_SAME_OUTPUT_THRESHOLD, + enableTranslation = preferences[ENABLE_TRANSLATION_KEY] ?: DEFAULT_ENABLE_TRANSLATION, + targetLanguage = preferences[TARGET_LANGUAGE_KEY] ?: DEFAULT_TARGET_LANGUAGE, + hotwords = preferences[HOTWORDS_KEY], + enableDiarization = preferences[ENABLE_DIARIZATION_KEY] ?: DEFAULT_ENABLE_DIARIZATION, + maxSpeakers = preferences[MAX_SPEAKERS_KEY] ?: DEFAULT_MAX_SPEAKERS, + wordTimestamps = preferences[WORD_TIMESTAMPS_KEY] ?: DEFAULT_WORD_TIMESTAMPS + ) + } + + suspend fun saveSettings(settings: HandshakeSettings) { + context.dataStore.edit { preferences -> + preferences[HOST_KEY] = settings.host + preferences[PORT_KEY] = settings.port + preferences[TASK_KEY] = settings.task + preferences[MODEL_KEY] = settings.model + preferences[LANGUAGE_KEY] = settings.language + preferences[USE_VAD_KEY] = settings.useVad + preferences[AUDIO_FORMAT_KEY] = settings.audioFormat + preferences[SEND_LAST_N_SEGMENTS_KEY] = settings.sendLastNSegments + preferences[NO_SPEECH_THRESH_KEY] = settings.noSpeechThresh + preferences[CLIP_AUDIO_KEY] = settings.clipAudio + preferences[SAME_OUTPUT_THRESHOLD_KEY] = settings.sameOutputThreshold + preferences[ENABLE_TRANSLATION_KEY] = settings.enableTranslation + preferences[TARGET_LANGUAGE_KEY] = settings.targetLanguage + if (settings.hotwords != null) { + preferences[HOTWORDS_KEY] = settings.hotwords + } else { + preferences.remove(HOTWORDS_KEY) + } + preferences[ENABLE_DIARIZATION_KEY] = settings.enableDiarization + preferences[MAX_SPEAKERS_KEY] = settings.maxSpeakers + preferences[WORD_TIMESTAMPS_KEY] = settings.wordTimestamps + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 324b701..8584cbc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.2.1" +agp = "9.3.0" coreKtx = "1.19.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -8,6 +8,7 @@ lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" kotlin = "2.2.10" composeBom = "2026.02.01" +datastore = "1.2.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,6 +25,9 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-core" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fd94def..1e2d38b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Sat Jun 27 14:10:17 EDT 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME