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