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)
This commit is contained in:
avi 2026-09-12 23:30:03 -05:00
commit 1475f48329
4 changed files with 370 additions and 22 deletions

View file

@ -36,7 +36,7 @@ import okhttp3.OkHttpClient
* framework. Talks to the SHONAR backend through the *shared* * framework. Talks to the SHONAR backend through the *shared*
* [CustomShonarProvider] (same files the Android app compiles). * [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 } enum class Screen { ENGINE, LIBRARY, DETAIL, SETTINGS }
@ -46,6 +46,8 @@ class DesktopState(appDir: File = defaultAppDir()) {
data class LibraryEntry( data class LibraryEntry(
val file: File, val file: File,
val hasReport: Boolean, val hasReport: Boolean,
val status: FileStatus = FileStatus.NEW,
val statusNote: String? = null,
) )
data class DetailUi( data class DetailUi(
@ -119,9 +121,25 @@ class DesktopState(appDir: File = defaultAppDir()) {
private var pollJob: Job? = null private var pollJob: Job? = null
/** Auto-queue new files as they appear (persisted preference). */
private val _autoTranscribe = MutableStateFlow(true)
val autoTranscribe: StateFlow<Boolean> = _autoTranscribe.asStateFlow()
/** Files the pump is running or has queued this session (by name). */
private val inFlight = mutableSetOf<String>()
/** Files whose last auto attempt failed (no hot-loop retries). */
private val failedFiles = mutableSetOf<String>()
/** Serial pump: one file at a time (engine runs one job anyway). */
private val queue = java.util.concurrent.ConcurrentLinkedQueue<File>()
private var pumpJob: Job? = null
private var watchJob: Job? = null
init { init {
scope.launch { scope.launch {
_serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000" _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 } } prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } }
?.let { setFolder(it, silent = true) } ?.let { setFolder(it, silent = true) }
ensureReady() ensureReady()
@ -150,6 +168,8 @@ class DesktopState(appDir: File = defaultAppDir()) {
} }
_engine.value = EngineState.STOPPED _engine.value = EngineState.STOPPED
_screen.value = Screen.ENGINE _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 _connected.value = true
refreshModels() refreshModels()
if (_screen.value == Screen.ENGINE) _screen.value = Screen.LIBRARY if (_screen.value == Screen.ENGINE) _screen.value = Screen.LIBRARY
if (_autoTranscribe.value) pumpNewFiles()
} }
private fun engineUp(): Boolean = runCatching { private fun engineUp(): Boolean = runCatching {
@ -164,6 +185,15 @@ class DesktopState(appDir: File = defaultAppDir()) {
.contains("\"ok\"") .contains("\"ok\"")
}.getOrDefault(false) }.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. */ /** Register-or-login the private local account. True when connected. */
private suspend fun provisionLocalAccount(): Boolean { private suspend fun provisionLocalAccount(): Boolean {
val parsed = ServerUrl.parse(_serverUrl.value).getOrNull() val parsed = ServerUrl.parse(_serverUrl.value).getOrNull()
@ -232,11 +262,12 @@ class DesktopState(appDir: File = defaultAppDir()) {
// ---- library ----------------------------------------------------------- // ---- library -----------------------------------------------------------
// ---- local engine ------------------------------------------------------ // ---- local engine ------------------------------------------------------
// The transcription engine is the SHONAR backend+worker running on this // The transcription engine is the SHONAR backend running on this
// machine. The app starts it on demand; the user never sees "servers". // 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 apiProc: Process? = null
private var workerProc: Process? = null
fun startEngine() { fun startEngine() {
scope.launch { scope.launch {
@ -260,30 +291,44 @@ class DesktopState(appDir: File = defaultAppDir()) {
prefs.putString(KEY_SECRET, fresh) prefs.putString(KEY_SECRET, fresh)
fresh fresh
} }
val env = mapOf( val env = mutableMapOf(
"SHONAR_SECRET_KEY" to secret, "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_PROVIDER" to "faster_whisper",
"SHONAR_TRANSCRIPTION_MODEL" to "base", "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() stopEngine()
apiProc = spawn(backend, File(venv, "uvicorn").absolutePath, apiProc = spawn(backend, File(venv, "uvicorn").absolutePath,
listOf("shonar.main:app", "--port", "8000"), env) listOf("shonar.main:app", "--port", "8000"), env)
workerProc = spawn(backend, File(venv, "arq").absolutePath,
listOf("shonar.worker.WorkerSettings"), env)
var up = false var up = false
repeat(40) { var waited = 0.0
delay(3000) while (waited < 120.0) {
if (engineUp()) { delay(1000)
waited += 1.0
if (apiProc?.isAlive == false) break
if (engineReady()) {
up = true up = true
return@repeat break
} }
} }
if (!up) { if (!up) {
stopEngine() stopEngine()
_engine.value = EngineState.FAILED _engine.value = EngineState.FAILED
_engineError.value = "Engine started but never answered " + _engineError.value = "Engine started but never became " +
"at ${_serverUrl.value}." "ready at ${_serverUrl.value}. See ${engineLogFile()}."
return@launch return@launch
} }
_engine.value = EngineState.RUNNING _engine.value = EngineState.RUNNING
@ -304,9 +349,24 @@ class DesktopState(appDir: File = defaultAppDir()) {
fun stopEngine() { fun stopEngine() {
apiProc?.destroy() apiProc?.destroy()
workerProc?.destroy()
apiProc = null 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? { private fun repoRoot(): File? {
@ -320,7 +380,9 @@ class DesktopState(appDir: File = defaultAppDir()) {
private fun spawn(dir: File, bin: String, args: List<String>, env: Map<String, String>): Process = private fun spawn(dir: File, bin: String, args: List<String>, env: Map<String, String>): Process =
ProcessBuilder(listOf(bin) + args).directory(dir).apply { ProcessBuilder(listOf(bin) + args).directory(dir).apply {
environment().putAll(env) environment().putAll(env)
redirectOutput(File.createTempFile("shonar-desktop-", ".log").also { it.deleteOnExit() }) val log = engineLogFile()
log.parentFile?.mkdirs()
redirectOutput(log)
redirectErrorStream(true) redirectErrorStream(true)
}.start() }.start()
@ -336,11 +398,152 @@ class DesktopState(appDir: File = defaultAppDir()) {
fun rescan() { fun rescan() {
val dir = _folder.value ?: return 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 } ?.filter { it.isFile && !it.name.startsWith(".") && it.extension.lowercase() in AUDIO_EXTS }
?.sortedBy { it.name.lowercase() } ?.sortedBy { it.name.lowercase() }
?.map { f -> LibraryEntry(f, reportFile(f).exists()) }
.orEmpty() .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<JobInfo> = 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) { fun setQuery(q: String) {
@ -493,6 +696,9 @@ class DesktopState(appDir: File = defaultAppDir()) {
const val KEY_FOLDER = "library.folder" const val KEY_FOLDER = "library.folder"
const val KEY_PASSWORD = "local.password" const val KEY_PASSWORD = "local.password"
const val KEY_SECRET = "local.secret" 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). // Must satisfy the engine's email validation (period in domain).
const val LOCAL_EMAIL = "desktop@app.shonar" const val LOCAL_EMAIL = "desktop@app.shonar"

View file

@ -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<File>,
/** Names that disappeared since the previous scan. */
val removed: Set<String>,
)
object LibraryQueue {
/** Audio candidates: regular files, known extension, not hidden. */
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
f.isFile && !f.name.startsWith(".") &&
f.extension.lowercase() in audioExts
fun diff(before: Set<String>, after: List<File>): 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<File>,
reportExists: (File) -> Boolean,
inFlightOrDone: Set<String>,
failed: Set<String>,
): List<File> = entries.filter {
!reportExists(it) && it.name !in inFlightOrDone && it.name !in failed
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -112,6 +113,13 @@ fun LibraryScreen(state: DesktopState) {
OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) { OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) {
Text("Choose folder…") 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) }, OutlinedTextField(query, { state.setQuery(it) },
label = { Text("Search saved transcripts") }, label = { Text("Search saved transcripts") },
@ -150,14 +158,28 @@ fun LibraryScreen(state: DesktopState) {
Text(e.file.nameWithoutExtension, Text(e.file.nameWithoutExtension,
style = MaterialTheme.typography.titleSmall) style = MaterialTheme.typography.titleSmall)
Text( Text(
"%.1f MB%s".format( "%.1f MB • %s".format(
e.file.length() / 1e6, 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, 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") } TextButton({ state.openDetail(e.file) }) { Text("Open") }
} }
} }

View file

@ -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 })
}
}