Android UI: Settings tab, compact recording view, scrollable config
- new Settings tab holds Transcribe on, Server URL, Library URL, Model, Language, Live model and the Download/Load engine actions (all persisted); Record tab keeps agenda, record, tools and transcripts - in-recording view hides all inputs: only level meter, agenda items, status, live transcript and the Stop button - idle config is scrollable with status + Record button fixed at the bottom - fix: the loaded engine and transcript lived in per-screen remember state and were silently lost on tab switches; now shared via AppState (engine is a heavyweight native object)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.meetrec.core.whisper.WhisperEngine
|
||||
|
||||
/**
|
||||
* App-wide state that must survive tab switches. Composables are disposed
|
||||
* when switching tabs, so per-screen remember state would lose the loaded
|
||||
* engine (a heavyweight native object) and the current transcript.
|
||||
*/
|
||||
object AppState {
|
||||
/** The locally loaded whisper engine (null = none loaded). */
|
||||
var engine: WhisperEngine? by mutableStateOf(null)
|
||||
|
||||
/** Last final transcript (manual transcribe or finished recording). */
|
||||
var segments: List<WhisperEngine.Segment> by mutableStateOf(emptyList())
|
||||
|
||||
/** Manually picked WAV for the re-transcribe path. */
|
||||
var wavName: String? by mutableStateOf(null)
|
||||
var wavSamples: FloatArray? by mutableStateOf(null)
|
||||
}
|
||||
@@ -43,13 +43,20 @@ fun MeetRecApp() {
|
||||
icon = { Text("☰") },
|
||||
label = { Text("Library") },
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = tab == 2,
|
||||
onClick = { tab = 2 },
|
||||
icon = { Text("⚙") },
|
||||
label = { Text("Settings") },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
when (tab) {
|
||||
0 -> RecordScreen()
|
||||
else -> LibraryScreen()
|
||||
1 -> LibraryScreen()
|
||||
else -> SettingsScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,16 +13,16 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -43,83 +43,43 @@ import com.meetrec.core.recording.WavReader
|
||||
import com.meetrec.core.whisper.RemoteWhisperEngine
|
||||
import com.meetrec.core.whisper.Transcriber
|
||||
import com.meetrec.core.whisper.WhisperEngine
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Record a meeting with live transcript (cheap model, rolling window) and
|
||||
* an automatic final pass with the selected model on stop — mirroring the
|
||||
* desktop app's two-pass design.
|
||||
* Record a meeting: agenda input, record/stop, live transcript and the
|
||||
* automatic final pass. All engine/server configuration lives on the
|
||||
* Settings tab (persisted); this screen reads it from SharedPreferences.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecordScreen() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val prefs = remember { context.getSharedPreferences("meetrec", Context.MODE_PRIVATE) }
|
||||
var transcribeOn by remember {
|
||||
mutableStateOf(prefs.getString("transcribe_on", "phone") ?: "phone")
|
||||
}
|
||||
var transcribeMenu by remember { mutableStateOf(false) }
|
||||
var serverUrl by remember {
|
||||
mutableStateOf(
|
||||
prefs.getString("server_url", "http://100.103.83.12:8085")
|
||||
?: "http://100.103.83.12:8085",
|
||||
)
|
||||
}
|
||||
var storageUrl by remember {
|
||||
mutableStateOf(
|
||||
prefs.getString("storage_url", "http://100.103.83.12:8090")
|
||||
?: "http://100.103.83.12:8090",
|
||||
)
|
||||
}
|
||||
var agendaText by remember { mutableStateOf(prefs.getString("agenda", "") ?: "") }
|
||||
|
||||
LaunchedEffect(transcribeOn) {
|
||||
prefs.edit().putString("transcribe_on", transcribeOn).apply()
|
||||
}
|
||||
LaunchedEffect(serverUrl) {
|
||||
prefs.edit().putString("server_url", serverUrl).apply()
|
||||
}
|
||||
LaunchedEffect(storageUrl) {
|
||||
prefs.edit().putString("storage_url", storageUrl).apply()
|
||||
}
|
||||
// configuration from the Settings tab — read at composition time; tabs
|
||||
// are disposed on switch, so returning here always sees fresh values
|
||||
val transcribeOn = prefs.getString("transcribe_on", "phone") ?: "phone"
|
||||
val serverUrl = prefs.getString("server_url", "") ?: ""
|
||||
val storageUrl = prefs.getString("storage_url", "") ?: ""
|
||||
val language = prefs.getString("language", "auto") ?: "auto"
|
||||
val liveModel = prefs.getString("live_model", "tiny") ?: "tiny"
|
||||
|
||||
var agendaText by remember { mutableStateOf(prefs.getString("agenda", "") ?: "") }
|
||||
var status by remember { mutableStateOf("Ready.") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(agendaText) {
|
||||
prefs.edit().putString("agenda", agendaText).apply()
|
||||
}
|
||||
|
||||
var model by remember { mutableStateOf("tiny") }
|
||||
var modelMenu by remember { mutableStateOf(false) }
|
||||
var language by remember { mutableStateOf("auto") }
|
||||
var langMenu by remember { mutableStateOf(false) }
|
||||
var liveModel by remember { mutableStateOf("tiny") }
|
||||
var liveMenu by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf("Select a model, download and load it.") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var engine by remember { mutableStateOf<WhisperEngine?>(null) }
|
||||
var wavName by remember { mutableStateOf<String?>(null) }
|
||||
var wavSamples by remember { mutableStateOf<FloatArray?>(null) }
|
||||
var segments by remember { mutableStateOf<List<WhisperEngine.Segment>>(emptyList()) }
|
||||
|
||||
val modelsDir = remember { File(context.filesDir, "models") }
|
||||
val modelFile = ModelDownloader.modelFile(model, modelsDir)
|
||||
|
||||
val recState by RecorderService.state.collectAsState()
|
||||
val live by RecorderService.liveLines.collectAsState()
|
||||
val upload by RecorderService.uploadState.collectAsState()
|
||||
|
||||
LaunchedEffect(upload) {
|
||||
when (val u = upload) {
|
||||
is UploadState.Uploading -> status = "Uploading recording to library…"
|
||||
is UploadState.Done -> status = "Uploaded to library (${u.id})"
|
||||
is UploadState.Error -> status = "Upload failed: ${u.message}"
|
||||
UploadState.None -> {}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> runBusy(block: suspend () -> T) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
@@ -138,18 +98,18 @@ fun RecordScreen() {
|
||||
val samples = withContext(Dispatchers.IO) {
|
||||
file.inputStream().use { WavReader.read(it) }
|
||||
}
|
||||
wavName = file.name
|
||||
wavSamples = samples
|
||||
AppState.wavName = file.name
|
||||
AppState.wavSamples = samples
|
||||
status = "${file.name} loaded — press Transcribe"
|
||||
}
|
||||
|
||||
// Final result: show the transcript and note the saved files.
|
||||
LaunchedEffect(recState) {
|
||||
val fin = recState as? RecordingState.Finished ?: return@LaunchedEffect
|
||||
segments = emptyList()
|
||||
wavSamples = null
|
||||
AppState.segments = emptyList()
|
||||
AppState.wavSamples = null
|
||||
if (fin.segments.isNotEmpty()) {
|
||||
segments = fin.segments
|
||||
AppState.segments = fin.segments
|
||||
status = if (fin.error != null) {
|
||||
"Final pass failed: ${fin.error}"
|
||||
} else {
|
||||
@@ -164,6 +124,16 @@ fun RecordScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// upload progress (recording library)
|
||||
LaunchedEffect(upload) {
|
||||
when (val u = upload) {
|
||||
is UploadState.Uploading -> status = "Uploading recording to library…"
|
||||
is UploadState.Done -> status = "Uploaded to library (${u.id})"
|
||||
is UploadState.Error -> status = "Upload failed: ${u.message}"
|
||||
UploadState.None -> {}
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording() = context.startForegroundService(
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(RecorderService.ACTION_START),
|
||||
@@ -198,7 +168,7 @@ fun RecordScreen() {
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
RecorderService.session = SessionConfig(
|
||||
finalEngine = if (transcribeOn == "phone") engine else null,
|
||||
finalEngine = if (transcribeOn == "phone") AppState.engine else null,
|
||||
serverUrl = if (transcribeOn == "server") {
|
||||
serverUrl.trim().ifBlank { null }
|
||||
} else {
|
||||
@@ -215,8 +185,8 @@ fun RecordScreen() {
|
||||
)
|
||||
if (transcribeOn == "server") {
|
||||
status = "Recording — live and final pass on ${serverUrl.trim()}"
|
||||
} else if (engine == null) {
|
||||
status = "Recording without transcription — load an engine first " +
|
||||
} else if (AppState.engine == null) {
|
||||
status = "Recording without transcription — load an engine in Settings " +
|
||||
"for the automatic final pass"
|
||||
}
|
||||
startRecording()
|
||||
@@ -237,9 +207,9 @@ fun RecordScreen() {
|
||||
val samples = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)!!.use { WavReader.read(it) }
|
||||
}
|
||||
wavName = name
|
||||
wavSamples = samples
|
||||
segments = emptyList()
|
||||
AppState.wavName = name
|
||||
AppState.wavSamples = samples
|
||||
AppState.segments = emptyList()
|
||||
status = "$name: ${samples.size / 16000} s @ 16 kHz"
|
||||
}
|
||||
}
|
||||
@@ -249,11 +219,12 @@ fun RecordScreen() {
|
||||
if (recState is RecordingState.Recording) {
|
||||
live.map { it.startMs to it.text }
|
||||
} else {
|
||||
segments.map { it.startMs to it.text }
|
||||
AppState.segments.map { it.startMs to it.text }
|
||||
}
|
||||
|
||||
val recording = recState as? RecordingState.Recording
|
||||
val finalizing = recState is RecordingState.Finalizing
|
||||
val agendaItems = agendaText.lines().map { it.trim() }.filter { it.isNotBlank() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
@@ -261,169 +232,134 @@ fun RecordScreen() {
|
||||
) {
|
||||
Text("MeetRec", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
// transcription target: the phone's local engines or a remote server
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = transcribeMenu,
|
||||
onExpandedChange = { transcribeMenu = it },
|
||||
modifier = Modifier.fillMaxWidth(0.6f),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = transcribeOn,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Transcribe on") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(transcribeMenu) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = transcribeMenu,
|
||||
onDismissRequest = { transcribeMenu = false },
|
||||
if (recording != null || finalizing) {
|
||||
// ---- in-recording view: only the essentials ----
|
||||
if (recording != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { recording.level.coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (agendaItems.isNotEmpty()) {
|
||||
Text("Agenda", style = MaterialTheme.typography.titleSmall)
|
||||
agendaItems.forEach { item -> Text("• $item") }
|
||||
}
|
||||
Text(status, style = MaterialTheme.typography.bodySmall)
|
||||
if (finalizing) CircularProgressIndicator()
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
items(transcript) { (startMs, text) ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(fmtMs(startMs), style = MaterialTheme.typography.labelSmall)
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ---- idle view: agenda + tools, scrollable ----
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
listOf("phone", "server").forEach { choice ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(choice) },
|
||||
onClick = { transcribeOn = choice; transcribeMenu = false },
|
||||
|
||||
// agenda: one item per line, checked against the transcript after upload
|
||||
OutlinedTextField(
|
||||
value = agendaText,
|
||||
onValueChange = { agendaText = it },
|
||||
label = { Text("Agenda (one item per line)") },
|
||||
placeholder = { Text("Budget\nZeitplan\n…") },
|
||||
minLines = 1,
|
||||
maxLines = 4,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// manual transcribe path (or re-transcribe of picked files)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) },
|
||||
enabled = !busy,
|
||||
) { Text(if (AppState.wavName == null) "Pick WAV file" else AppState.wavName ?: "WAV") }
|
||||
Button(
|
||||
onClick = {
|
||||
val samples = AppState.wavSamples
|
||||
if (samples != null &&
|
||||
(AppState.engine != null || transcribeOn == "server")
|
||||
) {
|
||||
runBusy {
|
||||
val t: Transcriber = if (transcribeOn == "server") {
|
||||
RemoteWhisperEngine(serverUrl.trim())
|
||||
} else {
|
||||
AppState.engine!!
|
||||
}
|
||||
val start = System.currentTimeMillis()
|
||||
val segs = withContext(Dispatchers.Default) {
|
||||
t.transcribe(
|
||||
samples,
|
||||
WhisperEngine.DEFAULT_THREADS,
|
||||
beamSize = 5,
|
||||
language = language.takeIf { it != "auto" },
|
||||
)
|
||||
}
|
||||
AppState.segments = segs
|
||||
val secs = (System.currentTimeMillis() - start) / 1000.0
|
||||
status = "${segs.size} segments in $secs s " +
|
||||
"(audio ${samples.size / 16000} s)"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy && AppState.wavSamples != null &&
|
||||
(AppState.engine != null || transcribeOn == "server"),
|
||||
) { Text("Transcribe") }
|
||||
}
|
||||
|
||||
if (transcript.isNotEmpty()) {
|
||||
OutlinedButton(onClick = {
|
||||
val text = transcript.joinToString("\n") {
|
||||
"[${fmtMs(it.first)}] ${it.second}"
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, text),
|
||||
"Share transcript",
|
||||
),
|
||||
)
|
||||
}) { Text("Share transcript") }
|
||||
|
||||
Text(
|
||||
"Transcript (${transcript.size})",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (transcribeOn == "server") {
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("http://100.103.83.12:8085") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
// recording library (leave empty to disable upload)
|
||||
OutlinedTextField(
|
||||
value = storageUrl,
|
||||
onValueChange = { storageUrl = it },
|
||||
label = { Text("Library URL") },
|
||||
placeholder = { Text("http://100.103.83.12:8090") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// agenda: one item per line, checked against the transcript after upload
|
||||
OutlinedTextField(
|
||||
value = agendaText,
|
||||
onValueChange = { agendaText = it },
|
||||
label = { Text("Agenda (one item per line)") },
|
||||
placeholder = { Text("Budget\nZeitplan\n…") },
|
||||
minLines = 1,
|
||||
maxLines = 4,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// model + language
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = modelMenu,
|
||||
onExpandedChange = { modelMenu = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = model,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = transcribeOn == "phone",
|
||||
label = { Text(if (transcribeOn == "server") "Model (n/a)" else "Model") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(modelMenu) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = modelMenu, onDismissRequest = { modelMenu = false }) {
|
||||
ModelDownloader.MODELS.keys.forEach { name ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name) },
|
||||
onClick = { model = name; modelMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = langMenu,
|
||||
onExpandedChange = { langMenu = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = language,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Language") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(langMenu) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
|
||||
LANGUAGES.forEach { code ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(code) },
|
||||
onClick = { language = code; langMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// live model (local engine only; the server owns its own models)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = liveMenu,
|
||||
onExpandedChange = { liveMenu = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = liveModel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = transcribeOn == "phone",
|
||||
label = {
|
||||
Text(if (transcribeOn == "server") "Live model (n/a)" else "Live model")
|
||||
},
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(liveMenu) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = liveMenu, onDismissRequest = { liveMenu = false }) {
|
||||
LIVE_MODELS.forEach { name ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name) },
|
||||
onClick = { liveModel = name; liveMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// model actions
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
runBusy {
|
||||
ModelDownloader.download(model, modelsDir) { done, total ->
|
||||
status = if (total > 0) "Downloading $model: $done / $total bytes"
|
||||
else "Downloading $model: $done bytes"
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
|
||||
) {
|
||||
items(transcript) { (startMs, text) ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
fmtMs(startMs),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
status = "Model $model ready at ${modelFile.name}"
|
||||
}
|
||||
},
|
||||
enabled = !busy && !modelFile.isFile && transcribeOn == "phone",
|
||||
) { Text("Download") }
|
||||
Button(
|
||||
onClick = {
|
||||
runBusy {
|
||||
engine?.close()
|
||||
withContext(Dispatchers.Default) {
|
||||
engine = WhisperEngine.load(modelFile.absolutePath)
|
||||
}
|
||||
status = "$model loaded (${WhisperEngine.DEFAULT_THREADS} threads)"
|
||||
}
|
||||
},
|
||||
enabled = !busy && modelFile.isFile && transcribeOn == "phone",
|
||||
) { Text("Load engine") }
|
||||
}
|
||||
} // scrollable column
|
||||
} // idle branch
|
||||
|
||||
// status stays visible above the record/stop button
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
) {
|
||||
Text(status, style = MaterialTheme.typography.bodySmall)
|
||||
if (busy || finalizing) CircularProgressIndicator()
|
||||
}
|
||||
|
||||
// recording
|
||||
// record / stop button, always at the bottom
|
||||
Button(
|
||||
onClick = { toggleRecording() },
|
||||
enabled = !finalizing && (!busy || recording != null),
|
||||
@@ -444,84 +380,9 @@ fun RecordScreen() {
|
||||
},
|
||||
)
|
||||
}
|
||||
if (recording != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { recording.level.coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
// manual transcribe path (or re-transcribe of picked files)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) },
|
||||
enabled = !busy,
|
||||
) { Text(if (wavName == null) "Pick WAV file" else wavName ?: "WAV") }
|
||||
Button(
|
||||
onClick = {
|
||||
val samples = wavSamples
|
||||
if (samples != null && (engine != null || transcribeOn == "server")) {
|
||||
runBusy {
|
||||
val t: Transcriber = if (transcribeOn == "server") {
|
||||
RemoteWhisperEngine(serverUrl.trim())
|
||||
} else {
|
||||
engine!!
|
||||
}
|
||||
val start = System.currentTimeMillis()
|
||||
val segs = withContext(Dispatchers.Default) {
|
||||
t.transcribe(
|
||||
samples,
|
||||
WhisperEngine.DEFAULT_THREADS,
|
||||
beamSize = 5,
|
||||
language = language.takeIf { it != "auto" },
|
||||
)
|
||||
}
|
||||
segments = segs
|
||||
val secs = (System.currentTimeMillis() - start) / 1000.0
|
||||
status = "${segs.size} segments in $secs s " +
|
||||
"(audio ${samples.size / 16000} s)"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy && wavSamples != null &&
|
||||
(engine != null || transcribeOn == "server"),
|
||||
) { Text("Transcribe") }
|
||||
}
|
||||
|
||||
if (transcript.isNotEmpty()) {
|
||||
OutlinedButton(onClick = {
|
||||
val text = transcript.joinToString("\n") { "[${fmtMs(it.first)}] ${it.second}" }
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, text),
|
||||
"Share transcript",
|
||||
),
|
||||
)
|
||||
}) { Text("Share transcript") }
|
||||
}
|
||||
|
||||
Text(status, style = MaterialTheme.typography.bodySmall)
|
||||
if (busy || finalizing) CircularProgressIndicator()
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
items(transcript) { (startMs, text) ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
fmtMs(startMs),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LANGUAGES = listOf("auto", "en", "de")
|
||||
private val LIVE_MODELS = listOf("off", "tiny", "base")
|
||||
|
||||
internal fun fmtMs(ms: Long): String {
|
||||
val total = ms / 1000
|
||||
val h = total / 3600
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meetrec.core.whisper.WhisperEngine
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val LANGUAGES = listOf("auto", "en", "de")
|
||||
private val LIVE_MODELS = listOf("off", "tiny", "base")
|
||||
|
||||
/**
|
||||
* Configuration: transcription target, server/library URLs, model,
|
||||
* language, live model — plus the Download/Load actions for the local
|
||||
* engine. All values persist in SharedPreferences.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val prefs = remember { context.getSharedPreferences("meetrec", Context.MODE_PRIVATE) }
|
||||
|
||||
var transcribeOn by remember {
|
||||
mutableStateOf(prefs.getString("transcribe_on", "phone") ?: "phone")
|
||||
}
|
||||
var transcribeMenu by remember { mutableStateOf(false) }
|
||||
var serverUrl by remember {
|
||||
mutableStateOf(
|
||||
prefs.getString("server_url", "http://100.103.83.12:8085")
|
||||
?: "http://100.103.83.12:8085",
|
||||
)
|
||||
}
|
||||
var storageUrl by remember {
|
||||
mutableStateOf(
|
||||
prefs.getString("storage_url", "http://100.103.83.12:8090")
|
||||
?: "http://100.103.83.12:8090",
|
||||
)
|
||||
}
|
||||
var model by remember { mutableStateOf(prefs.getString("model", "tiny") ?: "tiny") }
|
||||
var modelMenu by remember { mutableStateOf(false) }
|
||||
var language by remember { mutableStateOf(prefs.getString("language", "auto") ?: "auto") }
|
||||
var langMenu by remember { mutableStateOf(false) }
|
||||
var liveModel by remember { mutableStateOf(prefs.getString("live_model", "tiny") ?: "tiny") }
|
||||
var liveMenu by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val modelsDir = remember { File(context.filesDir, "models") }
|
||||
val modelFile = ModelDownloader.modelFile(model, modelsDir)
|
||||
|
||||
LaunchedEffect(transcribeOn) { prefs.edit().putString("transcribe_on", transcribeOn).apply() }
|
||||
LaunchedEffect(serverUrl) { prefs.edit().putString("server_url", serverUrl).apply() }
|
||||
LaunchedEffect(storageUrl) { prefs.edit().putString("storage_url", storageUrl).apply() }
|
||||
LaunchedEffect(model) { prefs.edit().putString("model", model).apply() }
|
||||
LaunchedEffect(language) { prefs.edit().putString("language", language).apply() }
|
||||
LaunchedEffect(liveModel) { prefs.edit().putString("live_model", liveModel).apply() }
|
||||
|
||||
fun <T> runBusy(block: suspend () -> T) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
scope.launch {
|
||||
try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
status = "Failed: ${e.message}"
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = transcribeMenu,
|
||||
onExpandedChange = { transcribeMenu = it },
|
||||
modifier = Modifier.fillMaxWidth(0.6f),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = transcribeOn,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Transcribe on") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(transcribeMenu) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = transcribeMenu,
|
||||
onDismissRequest = { transcribeMenu = false },
|
||||
) {
|
||||
listOf("phone", "server").forEach { choice ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(choice) },
|
||||
onClick = { transcribeOn = choice; transcribeMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (transcribeOn == "server") {
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("http://100.103.83.12:8085") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = storageUrl,
|
||||
onValueChange = { storageUrl = it },
|
||||
label = { Text("Library URL") },
|
||||
placeholder = { Text("http://100.103.83.12:8090") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// model + download/load (local engine)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = modelMenu,
|
||||
onExpandedChange = { modelMenu = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = model,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = transcribeOn == "phone",
|
||||
label = { Text(if (transcribeOn == "server") "Model (n/a)" else "Model") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(modelMenu) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = modelMenu,
|
||||
onDismissRequest = { modelMenu = false },
|
||||
) {
|
||||
ModelDownloader.MODELS.keys.forEach { name ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name) },
|
||||
onClick = { model = name; modelMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = langMenu,
|
||||
onExpandedChange = { langMenu = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = language,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Language") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(langMenu) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = langMenu,
|
||||
onDismissRequest = { langMenu = false },
|
||||
) {
|
||||
LANGUAGES.forEach { code ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(code) },
|
||||
onClick = { language = code; langMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
runBusy {
|
||||
ModelDownloader.download(model, modelsDir) { done, total ->
|
||||
status = if (total > 0) {
|
||||
"Downloading $model: $done / $total bytes"
|
||||
} else {
|
||||
"Downloading $model: $done bytes"
|
||||
}
|
||||
}
|
||||
status = "Model $model ready at ${modelFile.name}"
|
||||
}
|
||||
},
|
||||
enabled = !busy && !modelFile.isFile && transcribeOn == "phone",
|
||||
) { Text("Download") }
|
||||
Button(
|
||||
onClick = {
|
||||
runBusy {
|
||||
AppState.engine?.close()
|
||||
withContext(Dispatchers.Default) {
|
||||
AppState.engine = WhisperEngine.load(modelFile.absolutePath)
|
||||
}
|
||||
status = "$model loaded (${WhisperEngine.DEFAULT_THREADS} threads)"
|
||||
}
|
||||
},
|
||||
enabled = !busy && modelFile.isFile && transcribeOn == "phone",
|
||||
) { Text("Load engine") }
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = liveMenu,
|
||||
onExpandedChange = { liveMenu = it },
|
||||
modifier = Modifier.fillMaxWidth(0.6f),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = liveModel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = transcribeOn == "phone",
|
||||
label = {
|
||||
Text(if (transcribeOn == "server") "Live model (n/a)" else "Live model")
|
||||
},
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(liveMenu) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = liveMenu,
|
||||
onDismissRequest = { liveMenu = false },
|
||||
) {
|
||||
LIVE_MODELS.forEach { name ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name) },
|
||||
onClick = { liveModel = name; liveMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(status, style = MaterialTheme.typography.bodySmall)
|
||||
if (busy) CircularProgressIndicator()
|
||||
|
||||
Text(
|
||||
"Transcribe on: phone uses the local engine (download + load), " +
|
||||
"server sends both passes to the whisper.cpp server. Recordings " +
|
||||
"upload to the library when the Library URL is set.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user