Add Settings dialog and DataStore
This commit is contained in:
@@ -41,6 +41,7 @@ google-services.json
|
||||
*.iws
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/planningMode.xml
|
||||
.idea/vcs.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
|
||||
Generated
+7
@@ -4,6 +4,13 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-07-16T13:18:59.424006579Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/cjones/.android/avd/Pixel_9a.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Preferences> 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<HandshakeSettings> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user