From 1475f48329ae0327c143069646e3b46a4937d42d Mon Sep 17 00:00:00 2001 From: avi Date: Sat, 12 Sep 2026 23:30:03 -0500 Subject: [PATCH] Desktop: bundled-lite engine launch, folder watch + auto-transcribe queue - engine spawns in bundled-lite mode: SQLite at ~/.config/shonar-desktop/ engine.db, SHONAR_QUEUE_BACKEND=inline, auto-migrate; no worker proc - Ollama autodetect: if 127.0.0.1:11434 answers, LLM=ollama with qwen3:4b (fallback: any local qwen3); else summaries honestly off - readiness waits on /readyz database:true (not just /healthz) with early exit on process death; engine log persists to logs/engine.log - app auto-starts the engine instead of parking on the Engine screen - folder watcher (4s poll): drop-ins appear without manual rescan - auto-transcribe queue (persisted toggle): pump runs one file at a time, skips files with reports/in-flight/previously-failed (no hot loop); per-file Transcribe button for NEW/FAILED rows; status badges in list - pure LibraryQueue logic + 3 unit tests (desktop suite 8 green) --- .../kotlin/com/shonar/desktop/DesktopState.kt | 244 ++++++++++++++++-- .../kotlin/com/shonar/desktop/LibraryQueue.kt | 69 +++++ .../main/kotlin/com/shonar/desktop/Screens.kt | 28 +- .../com/shonar/desktop/LibraryQueueTest.kt | 51 ++++ 4 files changed, 370 insertions(+), 22 deletions(-) create mode 100644 desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt create mode 100644 desktop/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index 6e25fa1..df3c8b2 100644 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -36,7 +36,7 @@ import okhttp3.OkHttpClient * framework. Talks to the SHONAR backend through the *shared* * [CustomShonarProvider] (same files the Android app compiles). */ -class DesktopState(appDir: File = defaultAppDir()) { +class DesktopState(private val appDir: File = defaultAppDir()) { enum class Screen { ENGINE, LIBRARY, DETAIL, SETTINGS } @@ -46,6 +46,8 @@ class DesktopState(appDir: File = defaultAppDir()) { data class LibraryEntry( val file: File, val hasReport: Boolean, + val status: FileStatus = FileStatus.NEW, + val statusNote: String? = null, ) data class DetailUi( @@ -119,9 +121,25 @@ class DesktopState(appDir: File = defaultAppDir()) { private var pollJob: Job? = null + /** Auto-queue new files as they appear (persisted preference). */ + private val _autoTranscribe = MutableStateFlow(true) + val autoTranscribe: StateFlow = _autoTranscribe.asStateFlow() + + /** Files the pump is running or has queued this session (by name). */ + private val inFlight = mutableSetOf() + + /** Files whose last auto attempt failed (no hot-loop retries). */ + private val failedFiles = mutableSetOf() + + /** Serial pump: one file at a time (engine runs one job anyway). */ + private val queue = java.util.concurrent.ConcurrentLinkedQueue() + private var pumpJob: Job? = null + private var watchJob: Job? = null + init { scope.launch { _serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000" + _autoTranscribe.value = prefs.getString(KEY_AUTO) != "0" prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } } ?.let { setFolder(it, silent = true) } ensureReady() @@ -150,6 +168,8 @@ class DesktopState(appDir: File = defaultAppDir()) { } _engine.value = EngineState.STOPPED _screen.value = Screen.ENGINE + // The app owns the engine: start it instead of waiting. + startEngine() } } @@ -157,6 +177,7 @@ class DesktopState(appDir: File = defaultAppDir()) { _connected.value = true refreshModels() if (_screen.value == Screen.ENGINE) _screen.value = Screen.LIBRARY + if (_autoTranscribe.value) pumpNewFiles() } private fun engineUp(): Boolean = runCatching { @@ -164,6 +185,15 @@ class DesktopState(appDir: File = defaultAppDir()) { .contains("\"ok\"") }.getOrDefault(false) + /** Readiness (not just liveness): the engine's database is usable. */ + private fun engineReady(): Boolean = runCatching { + java.net.URL("${_serverUrl.value}/api/v1/readyz").readText() + .contains("\"database\":true") + }.getOrDefault(false) + + /** Persistent engine log so failures are actually debuggable. */ + fun engineLogFile(): File = File(appDir, "logs/engine.log") + /** Register-or-login the private local account. True when connected. */ private suspend fun provisionLocalAccount(): Boolean { val parsed = ServerUrl.parse(_serverUrl.value).getOrNull() @@ -232,11 +262,12 @@ class DesktopState(appDir: File = defaultAppDir()) { // ---- library ----------------------------------------------------------- // ---- local engine ------------------------------------------------------ - // The transcription engine is the SHONAR backend+worker running on this - // machine. The app starts it on demand; the user never sees "servers". + // The transcription engine is the SHONAR backend running on this + // machine in bundled-lite mode: SQLite + inline job queue, no + // Postgres/Redis/Docker. The app starts it on demand; the user never + // sees "servers". Summaries use Ollama when it's reachable locally. private var apiProc: Process? = null - private var workerProc: Process? = null fun startEngine() { scope.launch { @@ -260,30 +291,44 @@ class DesktopState(appDir: File = defaultAppDir()) { prefs.putString(KEY_SECRET, fresh) fresh } - val env = mapOf( + val env = mutableMapOf( "SHONAR_SECRET_KEY" to secret, - "SHONAR_STORAGE_PATH" to File(repo, "data/storage").absolutePath, + // Bundled-lite: one process, SQLite, in-process queue, + // schema migrates itself on boot. + "SHONAR_DATABASE_URL" to + "sqlite+aiosqlite:///${File(appDir, "engine.db").absolutePath}", + "SHONAR_QUEUE_BACKEND" to "inline", + "SHONAR_AUTO_MIGRATE" to "1", + "SHONAR_STORAGE_PATH" to File(appDir, "storage").absolutePath, "SHONAR_TRANSCRIPTION_PROVIDER" to "faster_whisper", "SHONAR_TRANSCRIPTION_MODEL" to "base", ) + if (ollamaUp()) { + env["SHONAR_LLM_PROVIDER"] = "ollama" + env["SHONAR_LLM_BASE_URL"] = "http://127.0.0.1:11434" + env["SHONAR_LLM_MODEL"] = ollamaModel() + } else { + env["SHONAR_LLM_PROVIDER"] = "none" + } stopEngine() apiProc = spawn(backend, File(venv, "uvicorn").absolutePath, listOf("shonar.main:app", "--port", "8000"), env) - workerProc = spawn(backend, File(venv, "arq").absolutePath, - listOf("shonar.worker.WorkerSettings"), env) var up = false - repeat(40) { - delay(3000) - if (engineUp()) { + var waited = 0.0 + while (waited < 120.0) { + delay(1000) + waited += 1.0 + if (apiProc?.isAlive == false) break + if (engineReady()) { up = true - return@repeat + break } } if (!up) { stopEngine() _engine.value = EngineState.FAILED - _engineError.value = "Engine started but never answered " + - "at ${_serverUrl.value}." + _engineError.value = "Engine started but never became " + + "ready at ${_serverUrl.value}. See ${engineLogFile()}." return@launch } _engine.value = EngineState.RUNNING @@ -304,9 +349,24 @@ class DesktopState(appDir: File = defaultAppDir()) { fun stopEngine() { apiProc?.destroy() - workerProc?.destroy() apiProc = null - workerProc = null + } + + private fun ollamaUp(): Boolean = runCatching { + java.net.URL("http://127.0.0.1:11434/api/tags").readText().contains("\"models\"") + }.getOrDefault(false) + + /** Prefer the tuned default; fall back to any qwen3 the user has. */ + private fun ollamaModel(): String { + val names = runCatching { + val body = java.net.URL("http://127.0.0.1:11434/api/tags").readText() + val arr = org.json.JSONObject(body).getJSONArray("models") + (0 until arr.length()).map { arr.getJSONObject(it).getString("name") } + }.getOrDefault(emptyList()) + return when { + names.any { it == DEFAULT_LLM_MODEL } -> DEFAULT_LLM_MODEL + else -> names.firstOrNull { it.startsWith("qwen3") } ?: DEFAULT_LLM_MODEL + } } private fun repoRoot(): File? { @@ -320,7 +380,9 @@ class DesktopState(appDir: File = defaultAppDir()) { private fun spawn(dir: File, bin: String, args: List, env: Map): Process = ProcessBuilder(listOf(bin) + args).directory(dir).apply { environment().putAll(env) - redirectOutput(File.createTempFile("shonar-desktop-", ".log").also { it.deleteOnExit() }) + val log = engineLogFile() + log.parentFile?.mkdirs() + redirectOutput(log) redirectErrorStream(true) }.start() @@ -336,11 +398,152 @@ class DesktopState(appDir: File = defaultAppDir()) { fun rescan() { val dir = _folder.value ?: return - _entries.value = dir.listFiles() + val files = dir.listFiles() ?.filter { it.isFile && !it.name.startsWith(".") && it.extension.lowercase() in AUDIO_EXTS } ?.sortedBy { it.name.lowercase() } - ?.map { f -> LibraryEntry(f, reportFile(f).exists()) } .orEmpty() + _entries.value = files.map { f -> + val done = reportFile(f).exists() + LibraryEntry( + file = f, + hasReport = done, + status = when { + done -> FileStatus.DONE + f.name in failedFiles -> FileStatus.FAILED + f.name in inFlight -> FileStatus.RUNNING + else -> FileStatus.NEW + }, + ) + } + startWatcher() + if (_autoTranscribe.value && _connected.value) pumpNewFiles() + } + + /** Poll the folder for changes (drop-ins appear without a manual rescan). */ + private fun startWatcher() { + if (watchJob?.isActive == true) return + watchJob = scope.launch { + var lastStamp = _folder.value?.lastModified() ?: 0L + var lastCount = _entries.value.size + while (true) { + delay(4000) + val dir = _folder.value ?: break + val count = dir.listFiles()?.size ?: 0 + val stamp = dir.lastModified() + if (stamp != lastStamp || count != lastCount) { + lastStamp = stamp + lastCount = count + rescan() + } + } + } + } + + fun setAutoTranscribe(on: Boolean) { + _autoTranscribe.value = on + scope.launch { prefs.putString(KEY_AUTO, if (on) "1" else "0") } + if (on) pumpNewFiles() + } + + /** Queue every candidate file that has no report and isn't in flight. */ + fun pumpNewFiles() { + val candidates = LibraryQueue.autoQueueCandidates( + entries = _entries.value.map { it.file }, + reportExists = { reportFile(it).exists() }, + inFlightOrDone = inFlight, + failed = failedFiles, + ) + if (candidates.isEmpty()) return + queue.addAll(candidates) + candidates.forEach { inFlight += it.name } + rescanStatuses() + startPump() + } + + /** Queue one specific file (manual Transcribe button). */ + fun pumpFile(f: File) { + if (f.name in inFlight) return + queue.add(f) + inFlight += f.name + rescanStatuses() + startPump() + } + + private fun startPump() { + if (pumpJob?.isActive == true) return + pumpJob = scope.launch { + while (queue.isNotEmpty() && _connected.value) { + val f = queue.poll() ?: break + val ok = transcribeFile(f) + if (!ok) failedFiles += f.name + else failedFiles -= f.name + inFlight -= f.name + rescanStatuses() + } + } + } + + private fun rescanStatuses() { + _entries.value = _entries.value.map { e -> + val done = reportFile(e.file).exists() + e.copy( + hasReport = done, + status = when { + done -> FileStatus.DONE + e.file.name in failedFiles -> FileStatus.FAILED + e.file.name in inFlight -> FileStatus.QUEUED + else -> FileStatus.NEW + }, + ) + } + } + + /** + * Background transcription of one file (no Detail UI coupling). + * Returns true when the report was written. Waits for BOTH the + * transcribe and summarize jobs to settle so the report is complete. + */ + suspend fun transcribeFile(f: File): Boolean { + if (_connected.value && runCatching { provider.reconnect() } + .getOrNull() != AuthState.CONNECTED) { + _connected.value = false + _engine.value = EngineState.STOPPED + return false + } + return try { + val draft = RecordingDraft( + id = UUID.randomUUID().toString(), + title = f.nameWithoutExtension, + createdAtEpochMs = f.lastModified().takeIf { it > 0 } + ?: System.currentTimeMillis(), + durationMs = 0, + mime = AUDIO_EXTS[f.extension.lowercase()] ?: "audio/mp4", + sourceFile = f, + sizeBytes = f.length(), + ) + val ref = provider.upload(draft) {} + var jobs: List = emptyList() + var polls = 0 + while (polls < 600) { // up to ~30 min per file at 3s polls + polls++ + delay(3000) + jobs = runCatching { parseJobs(provider.fetchJobs(ref.key)) }.getOrNull().orEmpty() + val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status + val s = jobs.firstOrNull { it.jobType == "summarize" }?.status + if ((t == null || t in TERMINAL) && (s == null || s in TERMINAL)) break + } + val transcribeFailed = jobs.any { + it.jobType == "transcribe" && it.status == "failed" + } + if (transcribeFailed) return false + val transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) } + .getOrNull() ?: return false + val summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull() + saveReport(DetailUi(file = f, transcript = transcript, summary = summary)) + true + } catch (e: Exception) { + false + } } fun setQuery(q: String) { @@ -493,6 +696,9 @@ class DesktopState(appDir: File = defaultAppDir()) { const val KEY_FOLDER = "library.folder" const val KEY_PASSWORD = "local.password" const val KEY_SECRET = "local.secret" + const val KEY_AUTO = "library.auto_transcribe" + /** Preferred local summarization model (Ollama). */ + const val DEFAULT_LLM_MODEL = "qwen3:4b" // Must satisfy the engine's email validation (period in domain). const val LOCAL_EMAIL = "desktop@app.shonar" diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt new file mode 100644 index 0000000..45f5eb1 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt @@ -0,0 +1,69 @@ +package com.shonar.desktop + +import java.io.File + +/** + * Pure library-queue logic (unit-tested; no coroutines, no Android). + * + * [plan] decides which files on disk are transcription candidates, and + * the queue pump consumes them in a stable order. All policy lives here + * so the state class stays plumbing. + */ + +enum class FileStatus { + /** No report yet — a candidate. */ + NEW, + + /** Queued for auto-transcription. */ + QUEUED, + + /** Upload/transcribe/summarize running now. */ + RUNNING, + + /** Report written next to the audio. */ + DONE, + + /** Last attempt failed (error message kept beside it). */ + FAILED, + ; +} + +/** + * Diff between two scans of the library folder. [added] preserves sorted + * file-name order so the queue is deterministic across rescans. + */ +class LibraryDiff( + val added: List, + /** Names that disappeared since the previous scan. */ + val removed: Set, +) + +object LibraryQueue { + + /** Audio candidates: regular files, known extension, not hidden. */ + fun isAudioCandidate(f: File, audioExts: Set): Boolean = + f.isFile && !f.name.startsWith(".") && + f.extension.lowercase() in audioExts + + fun diff(before: Set, after: List): LibraryDiff { + val afterNames = after.map { it.name }.toSet() + val added = after.filter { it.name !in before } + val removed = before - afterNames + return LibraryDiff(added, removed) + } + + /** + * Files to (re)queue for auto-transcription: candidates without a + * report, minus files already in-flight or previously failed since + * the last scan (a failure must not hot-loop; a rescan after the + * user edits/renames clears it naturally when the report appears). + */ + fun autoQueueCandidates( + entries: List, + reportExists: (File) -> Boolean, + inFlightOrDone: Set, + failed: Set, + ): List = entries.filter { + !reportExists(it) && it.name !in inFlightOrDone && it.name !in failed + } +} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 3110748..82c0456 100644 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -112,6 +113,13 @@ fun LibraryScreen(state: DesktopState) { OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) { Text("Choose folder…") } + Spacer(Modifier.width(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + val auto = state.autoTranscribe.collectAsState().value + Checkbox(auto, { state.setAutoTranscribe(it) }) + Text("Auto-transcribe new files", + style = MaterialTheme.typography.bodySmall) + } } OutlinedTextField(query, { state.setQuery(it) }, label = { Text("Search saved transcripts") }, @@ -150,14 +158,28 @@ fun LibraryScreen(state: DesktopState) { Text(e.file.nameWithoutExtension, style = MaterialTheme.typography.titleSmall) Text( - "%.1f MB%s".format( + "%.1f MB • %s".format( e.file.length() / 1e6, - if (e.hasReport) " • transcript saved" else "", + when (e.status) { + FileStatus.DONE -> "transcript saved" + FileStatus.QUEUED -> "queued for transcription" + FileStatus.RUNNING -> "transcribing…" + FileStatus.FAILED -> + "failed" + (e.statusNote?.let { " ($it)" } ?: "") + FileStatus.NEW -> "not transcribed" + }, ), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = when (e.status) { + FileStatus.FAILED -> MaterialTheme.colorScheme.error + FileStatus.DONE -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, ) } + if (e.status == FileStatus.NEW || e.status == FileStatus.FAILED) { + TextButton({ state.pumpFile(e.file) }) { Text("Transcribe") } + } TextButton({ state.openDetail(e.file) }) { Text("Open") } } } diff --git a/desktop/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt b/desktop/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt new file mode 100644 index 0000000..6e7b673 --- /dev/null +++ b/desktop/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt @@ -0,0 +1,51 @@ +package com.shonar.desktop + +import java.io.File +import kotlin.io.path.createTempDirectory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibraryQueueTest { + + private val exts = setOf("m4a", "wav", "mp3") + + @Test fun candidateOnlyForKnownAudioExt() { + val dir = createTempDirectory("lq").toFile() + val audio = File(dir, "a.m4a").apply { writeText("x") } + val note = File(dir, "notes.txt").apply { writeText("x") } + val hidden = File(dir, ".hidden.m4a").apply { writeText("x") } + assertTrue(LibraryQueue.isAudioCandidate(audio, exts)) + assertTrue(!LibraryQueue.isAudioCandidate(note, exts)) + assertTrue(!LibraryQueue.isAudioCandidate(hidden, exts)) + assertTrue(!LibraryQueue.isAudioCandidate(File(dir, "sub"), exts)) + } + + @Test fun diffDetectsAddedAndRemoved() { + val dir = createTempDirectory("lq").toFile() + val a = File(dir, "a.m4a").apply { writeText("x") } + val b = File(dir, "b.m4a").apply { writeText("x") } + val d = LibraryQueue.diff(setOf("gone.m4a"), listOf(a, b)) + assertEquals(listOf("a.m4a", "b.m4a"), d.added.map { it.name }) + assertEquals(setOf("gone.m4a"), d.removed) + } + + @Test fun autoQueueSkipsReportsInFlightAndFailed() { + val dir = createTempDirectory("lq").toFile() + val fresh = File(dir, "fresh.m4a").apply { writeText("x") } + val done = File(dir, "done.m4a").apply { writeText("x") } + File(dir, "done.transcript.md").writeText("# done") + val running = File(dir, "running.m4a").apply { writeText("x") } + val broken = File(dir, "broken.m4a").apply { writeText("x") } + + val picked = LibraryQueue.autoQueueCandidates( + entries = listOf(fresh, done, running, broken), + reportExists = { f -> + File(f.parentFile, "${f.nameWithoutExtension}.transcript.md").exists() + }, + inFlightOrDone = setOf("running.m4a"), + failed = setOf("broken.m4a"), + ) + assertEquals(listOf("fresh.m4a"), picked.map { it.name }) + } +}