Add Settings dialog and DataStore
This commit is contained in:
@@ -41,6 +41,7 @@ google-services.json
|
|||||||
*.iws
|
*.iws
|
||||||
.idea/workspace.xml
|
.idea/workspace.xml
|
||||||
.idea/tasks.xml
|
.idea/tasks.xml
|
||||||
|
.idea/planningMode.xml
|
||||||
.idea/vcs.xml
|
.idea/vcs.xml
|
||||||
.idea/dictionaries
|
.idea/dictionaries
|
||||||
.idea/libraries
|
.idea/libraries
|
||||||
|
|||||||
Generated
+7
@@ -4,6 +4,13 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<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 />
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ dependencies {
|
|||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation("com.squareup.okhttp3:okhttp:5.4.0")
|
implementation("com.squareup.okhttp3:okhttp:5.4.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.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.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
//import androidx.privacysandbox.tools.core.generator.build
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -53,6 +71,7 @@ import org.json.JSONObject
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
const val TAG = "SpeechClient"
|
const val TAG = "SpeechClient"
|
||||||
@@ -69,7 +88,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
private var buffer: ByteArray? = null
|
private var buffer: ByteArray? = null
|
||||||
private var isRecording by mutableStateOf(false)
|
private var isRecording by mutableStateOf(false)
|
||||||
private var transcriptText by mutableStateOf("Transcription will appear here...")
|
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(
|
private val requestPermissionLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { isGranted: Boolean ->
|
) { isGranted: Boolean ->
|
||||||
@@ -82,21 +105,52 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
settingsManager = SettingsManager(this)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
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 {
|
WhisperClient2Theme {
|
||||||
WhisperApp(transcriptText = transcriptText,
|
WhisperApp(
|
||||||
isRecording = isRecording) {
|
transcriptText = transcriptText,
|
||||||
// this block IS the onToggleRecording lambda
|
isRecording = isRecording,
|
||||||
if (isRecording)
|
connectionStatus = connectionStatus,
|
||||||
{
|
onOpenSettings = { showSettings = true },
|
||||||
stopRecording()
|
onToggleRecording = {
|
||||||
isRecording = false
|
if (isRecording) {
|
||||||
}
|
stopRecording()
|
||||||
else
|
isRecording = false
|
||||||
{
|
} else {
|
||||||
startRecording(); isRecording = true
|
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) {
|
if (audioRecordOK) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
connectionStatus = "Connecting"
|
||||||
|
}
|
||||||
// Create the WebSocket instance
|
// Create the WebSocket instance
|
||||||
|
val settings = settingsManager.settingsFlow.first()
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("ws://100.81.165.11:9097")
|
.url("ws://${settings.host}:${settings.port}")
|
||||||
.build()
|
.build()
|
||||||
val listener = object : WebSocketListener() {
|
val listener = object : WebSocketListener() {
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
// get a new client UUID (unique session ID) for each connection
|
// get a new client UUID (unique session ID) for each connection
|
||||||
clientUid = UUID.randomUUID()
|
clientUid = UUID.randomUUID()
|
||||||
// Build the handshake JSON using Android's built-in JSONObject.
|
// 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 {
|
val handshake = JSONObject().apply {
|
||||||
put("uid", clientUid.toString())
|
put("uid", clientUid.toString())
|
||||||
put("task", "transcribe")
|
put("task", settings.task)
|
||||||
put("model", "OpenVINO/whisper-medium-fp16-ov")
|
put("model", settings.model)
|
||||||
put("language", "en")
|
put("language", settings.language)
|
||||||
put("use_vad", false)
|
put("use_vad", settings.useVad)
|
||||||
put("audio_format", "int16")
|
put("audio_format", settings.audioFormat)
|
||||||
put("send_last_n_segments", 10)
|
put("send_last_n_segments", settings.sendLastNSegments)
|
||||||
put("no_speech_thresh", 0.45)
|
put("no_speech_thresh", settings.noSpeechThresh)
|
||||||
put("clip_audio", false)
|
put("clip_audio", settings.clipAudio)
|
||||||
put("same_output_threshold", 10)
|
put("same_output_threshold", settings.sameOutputThreshold)
|
||||||
put("enable_translation", false)
|
put("enable_translation", settings.enableTranslation)
|
||||||
put(
|
put("target_language", settings.targetLanguage)
|
||||||
"target_language",
|
put("hotwords", settings.hotwords ?: JSONObject.NULL)
|
||||||
"fr"
|
put("enable_diarization", settings.enableDiarization)
|
||||||
) // server requires this key even if translation is off
|
put("max_speakers", settings.maxSpeakers)
|
||||||
put("hotwords", JSONObject.NULL)
|
put("word_timestamps", settings.wordTimestamps)
|
||||||
put("enable_diarization", false)
|
|
||||||
put("max_speakers", 10)
|
|
||||||
put("word_timestamps", false)
|
|
||||||
}
|
}
|
||||||
// send() with a String argument sends a WebSocket text frame
|
// send() with a String argument sends a WebSocket text frame
|
||||||
webSocket.send(handshake.toString())
|
webSocket.send(handshake.toString())
|
||||||
@@ -223,12 +276,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
"WAIT" -> {
|
"WAIT" -> {
|
||||||
val minutes = message.getDouble("message").roundToInt()
|
val minutes = message.getDouble("message").roundToInt()
|
||||||
Log.i(TAG, "Server full. Estimated wait: $minutes minutes")
|
Log.i(TAG, "Server full. Estimated wait: $minutes minutes")
|
||||||
// Optionally show this in the UI
|
runOnUiThread {
|
||||||
|
connectionStatus = "Wait ($minutes min)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"ERROR" -> {
|
"ERROR" -> {
|
||||||
Log.e(TAG, "Server error: ${message.getString("message")}")
|
Log.e(TAG, "Server error: ${message.getString("message")}")
|
||||||
// Stop recording, show error in UI
|
runOnUiThread {
|
||||||
|
connectionStatus = "Error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"WARNING" -> {
|
"WARNING" -> {
|
||||||
@@ -249,7 +306,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
message.getString("backend") // e.g. "faster_whisper"
|
message.getString("backend") // e.g. "faster_whisper"
|
||||||
Log.i(TAG, "Server ready, backend: $backend")
|
Log.i(TAG, "Server ready, backend: $backend")
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
// statusTextView.text = "Connected"
|
connectionStatus = "Server Ready"
|
||||||
}
|
}
|
||||||
startAudioCapture() // <-- safe to start sending audio now
|
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(
|
override fun onFailure(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
t: Throwable,
|
t: Throwable,
|
||||||
response: Response?
|
response: Response?
|
||||||
) {
|
) {
|
||||||
Log.e(TAG, "WebSocket connection failed: ${t.message}", t)
|
Log.e(TAG, "WebSocket connection failed: ${t.message}", t)
|
||||||
|
runOnUiThread {
|
||||||
|
connectionStatus = "Error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +384,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
|
|
||||||
fun stopRecording() {
|
fun stopRecording() {
|
||||||
|
connectionStatus = "Disconnected"
|
||||||
stopAudioCapture()
|
stopAudioCapture()
|
||||||
recordingJob?.cancel() // the ?. is Kotlin null-safety — no NPE if job is null
|
recordingJob?.cancel() // the ?. is Kotlin null-safety — no NPE if job is null
|
||||||
webSocket?.close(1000, "Done")
|
webSocket?.close(1000, "Done")
|
||||||
@@ -358,14 +437,27 @@ fun WhisperApp(text: String)
|
|||||||
{ // ... inside your scrollable column ... Text( text = text,
|
{ // ... inside your scrollable column ... Text( text = text,
|
||||||
// It is now tied to the 'transcriptText' variable style = MaterialTheme.typography.bodyLarge ) }
|
// It is now tied to the 'transcriptText' variable style = MaterialTheme.typography.bodyLarge ) }
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun WhisperApp(
|
fun WhisperApp(
|
||||||
transcriptText: String,
|
transcriptText: String,
|
||||||
isRecording: Boolean = false,
|
isRecording: Boolean = false,
|
||||||
|
connectionStatus: String = "Disconnected",
|
||||||
|
onOpenSettings: () -> Unit = {},
|
||||||
onToggleRecording: () -> Unit = {}
|
onToggleRecording: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Whisper Client") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -393,19 +485,148 @@ fun WhisperApp(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
Row(
|
||||||
onClick = onToggleRecording,
|
modifier = Modifier
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
.fillMaxWidth()
|
||||||
content = { Text(if (isRecording) "Stop Transcription" else "Start Transcription") }
|
.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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun WhisperAppPreview() {
|
fun WhisperAppPreview() {
|
||||||
WhisperClient2Theme {
|
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]
|
[versions]
|
||||||
agp = "9.2.1"
|
agp = "9.3.0"
|
||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
@@ -8,6 +8,7 @@ lifecycleRuntimeKtx = "2.11.0"
|
|||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
|
datastore = "1.2.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
#Sat Jun 27 14:10:17 EDT 2026
|
#Sat Jun 27 14:10:17 EDT 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in New Issue
Block a user