Add Settings dialog and DataStore

This commit is contained in:
2026-07-16 16:37:36 -04:00
parent b73198260d
commit d2eb56ab16
7 changed files with 399 additions and 46 deletions
@@ -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"
)
}
}