diff --git a/.gitignore b/.gitignore index f174b13..dc310c4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,12 @@ android/app/build/ *.keystore local.properties +# --- Desktop (Compose for Desktop) --- +desktop/.gradle/ +desktop/.kotlin/ +desktop/build/ +desktop/app/build/ + # --- IDE / OS --- .idea/ .vscode/ diff --git a/desktop/app/build.gradle.kts b/desktop/app/build.gradle.kts new file mode 100644 index 0000000..d310cb6 --- /dev/null +++ b/desktop/app/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + kotlin("jvm") version "2.0.21" + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" + id("org.jetbrains.compose") version "1.7.3" +} + +group = "com.shonar" +version = "0.1.0" + +repositories { + mavenCentral() + google() +} + +kotlin { + jvmToolchain(17) +} + +sourceSets { + main { + kotlin { + // Shared with the Android app: compile the portable sources + // directly (same files, no copies). Android-only files excluded. + // (Path is relative to this module: desktop/app.) + srcDir("../../android/app/src/main/java") + exclude( + "com/shonar/ShonarApplication.kt", + "com/shonar/MainActivity.kt", + // settings store impls are Android (DataStore/Keystore); + // desktop provides its own (desktopSettingsStore.kt). + "com/shonar/settings/SettingsStore.kt", + // recording: only AiContent.kt is portable. + "com/shonar/recording/MigrationRunner.kt", + "com/shonar/recording/RecordingDao.kt", + "com/shonar/recording/RecordingEntity.kt", + "com/shonar/recording/RecordingRepository.kt", + "com/shonar/recording/RecordingService.kt", + "com/shonar/recording/ShonarDatabase.kt", + "com/shonar/recording/SyncDrain.kt", + "com/shonar/recording/SyncSlots.kt", + "com/shonar/recording/SyncStateConverter.kt", + "com/shonar/recording/SyncWorker.kt", + // ui: only ui/folder/FolderList.kt is portable. + "com/shonar/ui/detail/**", + "com/shonar/ui/home/**", + "com/shonar/ui/provider/**", + "com/shonar/ui/settings/**", + "com/shonar/ui/theme/**", + "com/shonar/ui/folder/FolderBrowserScreen.kt", + ) + srcDir("src/main/kotlin") + } + } + test { + kotlin { + srcDir("src/test/kotlin") + } + } +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + // Real org.json for JVM (Android stubs throw "not mocked"). + implementation("org.json:json:20240303") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") +} + +compose.desktop { + application { + mainClass = "com.shonar.desktop.MainKt" + nativeDistributions { + targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg) + targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi) + targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb) + packageName = "shonar-desktop" + packageVersion = "0.1.0" + } + } +} + +tasks.withType { + useJUnit() +} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt new file mode 100644 index 0000000..6e25fa1 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -0,0 +1,515 @@ +package com.shonar.desktop + +import com.shonar.provider.AuthState +import com.shonar.provider.CustomShonarProvider +import com.shonar.provider.ProviderCredential +import com.shonar.provider.ProviderError +import com.shonar.provider.RecordingDraft +import com.shonar.provider.ServerUrl +import com.shonar.provider.ShonarAuthStore +import com.shonar.provider.ShonarHandshake +import com.shonar.recording.JobInfo +import com.shonar.recording.ModelsData +import com.shonar.recording.SummaryData +import com.shonar.recording.TranscriptData +import com.shonar.recording.parseJobs +import com.shonar.recording.parseModels +import com.shonar.recording.parseSummary +import com.shonar.recording.parseTranscript +import com.shonar.settings.FileSettingsStore +import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient + +/** + * Desktop app state (Stage 3). Plain class + StateFlows — no ViewModel + * framework. Talks to the SHONAR backend through the *shared* + * [CustomShonarProvider] (same files the Android app compiles). + */ +class DesktopState(appDir: File = defaultAppDir()) { + + enum class Screen { ENGINE, LIBRARY, DETAIL, SETTINGS } + + /** Local transcription engine lifecycle (the bundled backend+worker). */ + enum class EngineState { UNKNOWN, STARTING, RUNNING, STOPPED, FAILED } + + data class LibraryEntry( + val file: File, + val hasReport: Boolean, + ) + + data class DetailUi( + val file: File, + /** "Use default" is null; otherwise the override model name. */ + var overrideModel: String? = null, + var busy: String? = null, + var uploadProgress: Float? = null, + var remoteId: String? = null, + var jobs: List = emptyList(), + var transcript: TranscriptData? = null, + var summary: SummaryData? = null, + var error: String? = null, + ) + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val prefs = FileSettingsStore(File(appDir, "settings.json")) + private val authStore = ShonarAuthStore(FileSettingsStore(File(appDir, "auth.json"))) + + private val http = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(300, TimeUnit.SECONDS) + .writeTimeout(300, TimeUnit.SECONDS) + .build() + + val provider = CustomShonarProvider( + auth = authStore, + sidecarRoot = File(appDir, "sidecars"), + client = http, + handshake = ShonarHandshake(http), + deviceName = "SHONAR Desktop", + platform = "desktop", + ) + + private val _screen = MutableStateFlow(Screen.LIBRARY) + val screen: StateFlow = _screen.asStateFlow() + + private val _serverUrl = MutableStateFlow("http://localhost:8000") + val serverUrl: StateFlow = _serverUrl.asStateFlow() + + private val _engine = MutableStateFlow(EngineState.UNKNOWN) + val engine: StateFlow = _engine.asStateFlow() + + private val _engineError = MutableStateFlow(null) + val engineError: StateFlow = _engineError.asStateFlow() + + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _connected.asStateFlow() + + private val _folder = MutableStateFlow(null) + val folder: StateFlow = _folder.asStateFlow() + + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + private val _models = MutableStateFlow(null) + val models: StateFlow = _models.asStateFlow() + + private val _modelsError = MutableStateFlow(null) + val modelsError: StateFlow = _modelsError.asStateFlow() + + private val _detail = MutableStateFlow(null) + val detail: StateFlow = _detail.asStateFlow() + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _searchResults = MutableStateFlow>(emptyList()) + val searchResults: StateFlow> = _searchResults.asStateFlow() + + private var pollJob: Job? = null + + init { + scope.launch { + _serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000" + prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } } + ?.let { setFolder(it, silent = true) } + ensureReady() + } + Runtime.getRuntime().addShutdownHook(Thread { stopEngine() }) + } + + /** + * Startup chain, no questions asked: saved session → local account on a + * running engine → start the engine ourselves → engine screen on failure. + * There is deliberately no login UI: the desktop owns a local account + * (email + random password in its own settings file) that exists only + * to satisfy the engine's auth. + */ + fun ensureReady() { + scope.launch { + if (runCatching { provider.reconnect() }.getOrNull() == AuthState.CONNECTED) { + onReady() + return@launch + } + if (engineUp()) { + if (provisionLocalAccount()) { + onReady() + return@launch + } + } + _engine.value = EngineState.STOPPED + _screen.value = Screen.ENGINE + } + } + + private suspend fun onReady() { + _connected.value = true + refreshModels() + if (_screen.value == Screen.ENGINE) _screen.value = Screen.LIBRARY + } + + private fun engineUp(): Boolean = runCatching { + java.net.URL("${_serverUrl.value}/api/v1/healthz").readText() + .contains("\"ok\"") + }.getOrDefault(false) + + /** Register-or-login the private local account. True when connected. */ + private suspend fun provisionLocalAccount(): Boolean { + val parsed = ServerUrl.parse(_serverUrl.value).getOrNull() + if (parsed == null) { + _engineError.value = "Engine address is not a valid URL." + return false + } + if (provider.probe(parsed) !is com.shonar.provider.ProbeResult.Compatible) { + _engineError.value = "Engine answered but doesn't look like SHONAR." + return false + } + var password = prefs.getString(KEY_PASSWORD) + if (password.isNullOrBlank()) { + password = UUID.randomUUID().toString() + UUID.randomUUID().toString() + prefs.putString(KEY_PASSWORD, password) + } + val cred = ProviderCredential.ShonarLogin( + accountLabel = LOCAL_EMAIL, serverUrl = parsed, + email = LOCAL_EMAIL, password = password, + ) + if (runCatching { provider.connect(cred) }.isSuccess) return true + // Stored password is stale — register anew, then connect. + val registered = runCatching { registerLocalAccount(parsed, password) }.isSuccess + if (!registered) { + _engineError.value = "Engine is up but the local account was refused." + return false + } + return runCatching { + provider.connect(cred) + true + }.getOrElse { + _engineError.value = it.message ?: "Engine is up but sign-in failed." + false + } + } + + private fun registerLocalAccount(base: ServerUrl, password: String) { + val body = """{"email":${jsonStr(LOCAL_EMAIL)},"password":${jsonStr(password)}}""" + val url = java.net.URL(base.origin + "/api/v1/auth/register") + (url.openConnection() as java.net.HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json") + doOutput = true + outputStream.use { it.write(body.toByteArray()) } + if (responseCode != 201) throw IllegalStateException("register: HTTP $responseCode") + }.disconnect() + } + + private fun jsonStr(raw: String): String = buildString { + append('"') + for (c in raw) when (c) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + else -> append(c) + } + append('"') + } + + fun go(screen: Screen) { + _screen.value = screen + if (screen == Screen.LIBRARY) rescan() + } + + // ---- account (invisible local account; no login UI) --------------------- + + // ---- 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". + + private var apiProc: Process? = null + private var workerProc: Process? = null + + fun startEngine() { + scope.launch { + _engine.value = EngineState.STARTING + _engineError.value = null + val repo = repoRoot() + if (repo == null) { + _engine.value = EngineState.FAILED + _engineError.value = "Could not find the SHONAR checkout " + + "(set SHONAR_REPO to its path)." + return@launch + } + try { + val backend = File(repo, "backend") + val venv = File(backend, ".venv/bin") + require(File(venv, "uvicorn").canExecute()) { + "Backend not installed at ${backend.path}." + } + val secret = prefs.getString(KEY_SECRET) ?: run { + val fresh = UUID.randomUUID().toString() + UUID.randomUUID().toString() + prefs.putString(KEY_SECRET, fresh) + fresh + } + val env = mapOf( + "SHONAR_SECRET_KEY" to secret, + "SHONAR_STORAGE_PATH" to File(repo, "data/storage").absolutePath, + "SHONAR_TRANSCRIPTION_PROVIDER" to "faster_whisper", + "SHONAR_TRANSCRIPTION_MODEL" to "base", + ) + 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()) { + up = true + return@repeat + } + } + if (!up) { + stopEngine() + _engine.value = EngineState.FAILED + _engineError.value = "Engine started but never answered " + + "at ${_serverUrl.value}." + return@launch + } + _engine.value = EngineState.RUNNING + if (provisionLocalAccount()) onReady() + else if (_engineError.value == null) { + _engine.value = EngineState.FAILED + _engineError.value = "Engine is up but sign-in failed." + } else { + _engine.value = EngineState.FAILED + } + } catch (e: Exception) { + stopEngine() + _engine.value = EngineState.FAILED + _engineError.value = e.message ?: "Could not start the engine." + } + } + } + + fun stopEngine() { + apiProc?.destroy() + workerProc?.destroy() + apiProc = null + workerProc = null + } + + private fun repoRoot(): File? { + val env = System.getenv("SHONAR_REPO")?.let { File(it) } + val home = File("/home/avi/Projects/Shonar") + return listOfNotNull(env, home).firstOrNull { + File(it, "backend/.venv/bin/uvicorn").canExecute() + } + } + + 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() }) + redirectErrorStream(true) + }.start() + + // ---- library ----------------------------------------------------------- + + fun pickFolder(dir: File) = setFolder(dir) + + private fun setFolder(dir: File, silent: Boolean = false) { + _folder.value = dir + if (!silent) scope.launch { prefs.putString(KEY_FOLDER, dir.absolutePath) } + rescan() + } + + fun rescan() { + val dir = _folder.value ?: return + _entries.value = 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() + } + + fun setQuery(q: String) { + _query.value = q + val needle = q.trim().lowercase() + val dir = _folder.value + if (needle.isEmpty() || dir == null) { + _searchResults.value = emptyList() + return + } + val hits = mutableListOf() + dir.listFiles() + ?.filter { it.isFile && it.name.endsWith(".transcript.md") } + ?.sortedBy { it.name.lowercase() } + ?.forEach { report -> + report.readLines().forEachIndexed { i, line -> + val at = line.lowercase().indexOf(needle) + if (at >= 0) { + val start = (at - 40).coerceAtLeast(0) + val end = (at + needle.length + 80).coerceAtMost(line.length) + hits += SearchHit(report.nameWithoutExtension, i + 1, line.substring(start, end).trim()) + if (hits.size >= 200) return@forEach + } + } + } + _searchResults.value = hits + } + + // ---- models ------------------------------------------------------------ + + fun refreshModels() { + scope.launch { + _modelsError.value = null + _models.value = runCatching { parseModels(provider.fetchModels()) }.getOrElse { + _modelsError.value = it.message ?: "Could not load models." + return@launch + } + } + } + + fun setDefaultModel(name: String) { + scope.launch { + _modelsError.value = null + runCatching { provider.setDefaultModel(name) }.onFailure { + _modelsError.value = it.message ?: "Could not change the default." + return@launch + } + refreshModels() + } + } + + fun downloadModel(name: String) { + scope.launch { + _modelsError.value = "Downloading $name — this can take a while…" + runCatching { provider.downloadModel(name) }.onFailure { + _modelsError.value = it.message ?: "Download failed." + return@launch + } + _modelsError.value = null + refreshModels() + } + } + + // ---- detail / transcribe ----------------------------------------------- + + fun openDetail(file: File) { + pollJob?.cancel() + _detail.value = DetailUi(file = file) + go(Screen.DETAIL) + } + + fun setOverride(model: String?) { + _detail.value?.let { it.overrideModel = model; _detail.value = it.copy() } + } + + fun transcribe() { + val d = _detail.value ?: return + pollJob?.cancel() + pollJob = scope.launch { + d.busy = "Uploading…" + d.uploadProgress = 0f + d.error = null + _detail.value = d.copy() + val draft = RecordingDraft( + id = UUID.randomUUID().toString(), + title = d.file.nameWithoutExtension, + createdAtEpochMs = d.file.lastModified().takeIf { it > 0 } + ?: System.currentTimeMillis(), + durationMs = 0, + mime = AUDIO_EXTS[d.file.extension.lowercase()] ?: "audio/mp4", + sourceFile = d.file, + sizeBytes = d.file.length(), + transcriptionModel = d.overrideModel, + ) + val ref = runCatching { + provider.upload(draft) { p -> + d.uploadProgress = p + _detail.value = d.copy() + } + }.getOrElse { + d.busy = null + d.error = it.message ?: "Upload failed." + _detail.value = d.copy() + return@launch + } + d.remoteId = ref.key + d.busy = "Transcribing…" + d.uploadProgress = null + _detail.value = d.copy() + // Poll jobs until the transcribe step reaches a terminal state. + while (true) { + delay(3000) + val jobs = runCatching { parseJobs(provider.fetchJobs(ref.key)) }.getOrNull() + .orEmpty() + d.jobs = jobs + _detail.value = d.copy() + val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status + if (t == null || t in TERMINAL) break + } + val failed = d.jobs.firstOrNull { it.jobType == "transcribe" && it.status == "failed" } + if (failed != null) { + d.busy = null + d.error = failed.error ?: "Transcription failed." + _detail.value = d.copy() + return@launch + } + d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) } + .getOrNull() + d.summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull() + d.busy = null + _detail.value = d.copy() + if (d.transcript != null) { + saveReport(d) + rescan() + } else { + d.error = "Transcription finished but no transcript was returned." + _detail.value = d.copy() + } + } + } + + fun retry() = transcribe() + + fun saveReport(d: DetailUi) { + reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary)) + } + + companion object { + const val KEY_URL = "server.url" + const val KEY_FOLDER = "library.folder" + const val KEY_PASSWORD = "local.password" + const val KEY_SECRET = "local.secret" + // Must satisfy the engine's email validation (period in domain). + const val LOCAL_EMAIL = "desktop@app.shonar" + + val AUDIO_EXTS = mapOf( + "m4a" to "audio/mp4", "mp4" to "audio/mp4", "aac" to "audio/aac", + "wav" to "audio/wav", "ogg" to "audio/ogg", "opus" to "audio/ogg", + "webm" to "audio/webm", "mp3" to "audio/mpeg", + ) + + val TERMINAL = setOf("succeeded", "failed", "skipped") + + fun defaultAppDir(): File = + File(System.getProperty("user.home"), ".config/shonar-desktop").apply { mkdirs() } + + fun reportFile(audio: File): File = + File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md") + } +} + +data class SearchHit(val report: String, val line: Int, val snippet: String) diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Main.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Main.kt new file mode 100644 index 0000000..8804685 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/Main.kt @@ -0,0 +1,38 @@ +package com.shonar.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState + +fun main() = application { + val state = remember { DesktopState() } + val screen by state.screen.collectAsState() + Window( + onCloseRequest = ::exitApplication, + title = "SHONAR Desktop", + state = rememberWindowState(width = 1100.dp, height = 800.dp), + ) { + MaterialTheme { + Surface(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().padding(20.dp)) { + when (screen) { + DesktopState.Screen.ENGINE -> EngineScreen(state) + DesktopState.Screen.LIBRARY -> LibraryScreen(state) + DesktopState.Screen.DETAIL -> DetailScreen(state) + DesktopState.Screen.SETTINGS -> SettingsScreen(state) + } + } + } + } + } +} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Reports.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Reports.kt new file mode 100644 index 0000000..a2f3fd4 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/Reports.kt @@ -0,0 +1,48 @@ +package com.shonar.desktop + +import com.shonar.recording.SUMMARY_LIST_KEYS +import com.shonar.recording.SummaryData +import com.shonar.recording.TranscriptData + +/** + * Plaud-style Markdown report written next to the audio file. + * Pure logic — unit-tested. + */ +fun renderReport(title: String, transcript: TranscriptData?, summary: SummaryData?): String { + val out = mutableListOf("# $title", "") + if (summary != null) { + if (summary.short.isNotBlank()) { + out += listOf("## Summary", "", summary.short, "") + } + for (key in SUMMARY_LIST_KEYS) { + val items = summary.list(key) + if (items.isNotEmpty()) { + out += listOf("## " + key.replace('_', ' ').replaceFirstChar { it.uppercase() }, "") + out += items.map { "- $it" } + listOf("") + } + } + if (summary.detailed.isNotBlank()) { + out += listOf("## Details", "", summary.detailed, "") + } + } + if (transcript != null) { + out += listOf("## Transcript", "") + val segs = transcript.segments + if (segs.isNotEmpty()) { + segs.forEach { s -> out += "[${fmtTs(s.startSec)}] ${s.text}" } + out += "" + } else { + out += listOf(transcript.text, "") + } + val prov = transcript.provider.ifBlank { "?" } + out += "*Transcribed with $prov, v${transcript.version}.*" + } else { + out += listOf("## Transcript", "", "_No transcript available._", "") + } + return out.joinToString("\n") +} + +fun fmtTs(sec: Double): String { + val total = sec.toLong().coerceAtLeast(0) + return "%02d:%02d:%02d".format(total / 3600, (total % 3600) / 60, total % 60) +} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt new file mode 100644 index 0000000..3110748 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -0,0 +1,367 @@ +package com.shonar.desktop + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.io.File +import javax.swing.JFileChooser + +// ---- engine ----------------------------------------------------------------- + +@androidx.compose.runtime.Composable +fun EngineScreen(state: DesktopState) { + val engine by state.engine.collectAsState() + val error by state.engineError.collectAsState() + + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Starting the transcription engine…", + style = MaterialTheme.typography.headlineSmall) + Text( + "SHONAR transcribes on this computer with local models — nothing " + + "is uploaded anywhere. The engine runs in the background while " + + "this app is open.", + style = MaterialTheme.typography.bodyMedium, + ) + when (engine) { + DesktopState.EngineState.STARTING, DesktopState.EngineState.UNKNOWN -> { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) + Spacer(Modifier.width(10.dp)) + Text("Starting…") + } + } + else -> { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button({ state.startEngine() }) { Text("Start engine") } + OutlinedButton({ state.ensureReady() }) { Text("Retry") } + } + } + } + } +} + +// ---- library --------------------------------------------------------------- + +private fun chooseFolder(): File? { + val chooser = JFileChooser().apply { + dialogTitle = "Choose your recordings folder" + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false + } + return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null +} + +@androidx.compose.runtime.Composable +fun LibraryScreen(state: DesktopState) { + val folder by state.folder.collectAsState() + val entries by state.entries.collectAsState() + val query by state.query.collectAsState() + val hits by state.searchResults.collectAsState() + val connected by state.connected.collectAsState() + + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Recordings", style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f)) + TextButton({ state.go(DesktopState.Screen.SETTINGS) }) { Text("Settings") } + } + if (!connected) { + Card(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically) { + Text("Transcription engine isn't running.", + modifier = Modifier.weight(1f)) + TextButton({ state.go(DesktopState.Screen.ENGINE) }) { + Text("Start it") + } + } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(folder?.absolutePath ?: "No folder chosen", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) { + Text("Choose folder…") + } + } + OutlinedTextField(query, { state.setQuery(it) }, + label = { Text("Search saved transcripts") }, + singleLine = true, modifier = Modifier.fillMaxWidth()) + if (query.isNotBlank()) { + if (hits.isEmpty()) { + Text("No matches.", style = MaterialTheme.typography.bodySmall) + } else { + LazyColumn(Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(hits) { h -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(10.dp)) { + Text(h.report, style = MaterialTheme.typography.titleSmall) + Text("line ${h.line}: …${h.snippet}…", + style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } else if (folder == null) { + Text("Pick the folder with your .m4a recordings to begin.", + color = MaterialTheme.colorScheme.onSurfaceVariant) + } else if (entries.isEmpty()) { + Text("No audio files in this folder.", + color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + LazyColumn(Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + items(entries, key = { it.file.absolutePath }) { e -> + Card(Modifier.fillMaxWidth().clickable { state.openDetail(e.file) }) { + Row(Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(e.file.nameWithoutExtension, + style = MaterialTheme.typography.titleSmall) + Text( + "%.1f MB%s".format( + e.file.length() / 1e6, + if (e.hasReport) " • transcript saved" else "", + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton({ state.openDetail(e.file) }) { Text("Open") } + } + } + } + } + } + } +} + +// ---- detail ----------------------------------------------------------------- + +@androidx.compose.runtime.Composable +fun DetailScreen(state: DesktopState) { + val detail = state.detail.collectAsState().value ?: run { + state.go(DesktopState.Screen.LIBRARY) + return + } + val models = state.models.collectAsState().value + var showModels by remember { mutableStateOf(false) } + + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } + Text(detail.file.nameWithoutExtension, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f)) + } + // Model override: "Use default" or a specific size. + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Model: ", style = MaterialTheme.typography.bodyMedium) + val current = detail.overrideModel + ?: models?.defaultModel + ?: "base" + TextButton({ showModels = !showModels }) { + Text(if (detail.overrideModel == null) "Use default ($current)" else current) + } + } + if (showModels && models != null) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + ModelRow(state, detail, null, models.defaultModel, + "Use default (${models.defaultModel})", "") + models.models.forEach { m -> + ModelRow(state, detail, m.name, models.defaultModel, + m.displayName, m.description + + (if (!m.available) " — not downloaded" else "")) + } + } + Spacer(Modifier.height(4.dp)) + } + detail.uploadProgress?.let { + LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth()) + Text("Uploading… ${(it * 100).toInt()}%") + } + detail.busy?.takeIf { detail.uploadProgress == null }?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) + Spacer(Modifier.width(10.dp)) + Text(it) + } + } + val tJob = detail.jobs.firstOrNull { it.jobType == "transcribe" } + tJob?.let { + val label = when { + it.status == "succeeded" -> "Transcribed" + it.status == "failed" -> "Failed" + it.stage == "loading-model" -> "Loading model…" + it.stage == "transcribing" -> "Transcribing…" + it.status == "running" -> "Working… (attempt ${it.attempt}/${it.maxAttempts})" + else -> "Queued" + } + Text(label, style = MaterialTheme.typography.bodyMedium, + color = if (it.status == "failed") MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary) + } + detail.error?.let { + Text(it, color = MaterialTheme.colorScheme.error) + if (tJob?.status == "failed") { + Button({ state.retry() }) { Text("Retry transcription") } + } + } + if (detail.busy == null && detail.transcript == null && detail.error == null) { + Button({ state.transcribe() }) { Text("Transcribe") } + } + detail.transcript?.let { t -> + Text("Transcript", style = MaterialTheme.typography.titleMedium) + LazyColumn(Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (t.segments.isNotEmpty()) { + items(t.segments.size) { i -> + val s = t.segments[i] + Text("[${fmtTs(s.startSec)}] ${s.text}", + style = MaterialTheme.typography.bodyMedium) + } + } else { + item { Text(t.text) } + } + } + Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + detail.summary?.let { s -> + if (s.short.isNotBlank()) { + Text("Summary", style = MaterialTheme.typography.titleMedium) + Text(s.short) + } + } + Spacer(Modifier.height(4.dp)) + Text("Summaries via Hermes land in Stage 4 — transcription above is complete without it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@androidx.compose.runtime.Composable +private fun ModelRow( + state: DesktopState, + detail: DesktopState.DetailUi, + value: String?, + default: String, + title: String, + subtitle: String, +) { + val selected = detail.overrideModel == value || + (value == null && detail.overrideModel == null) + Card( + Modifier.fillMaxWidth().clickable { state.setOverride(value) }, + ) { + Row(Modifier.fillMaxWidth().padding(10.dp), + verticalAlignment = Alignment.CenterVertically) { + Text(if (selected) "◉ " else "○ ") + Column { + Text(title, style = MaterialTheme.typography.bodyMedium) + if (subtitle.isNotBlank()) { + Text(subtitle, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } +} + +// ---- settings ----------------------------------------------------------------- + +@androidx.compose.runtime.Composable +fun SettingsScreen(state: DesktopState) { + val models = state.models.collectAsState().value + val modelsError = state.modelsError.collectAsState().value + val url by state.serverUrl.collectAsState() + + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } + Text("Settings", style = MaterialTheme.typography.headlineSmall) + } + Text("Local engine: $url", style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(4.dp)) + Text("Default transcription model", style = MaterialTheme.typography.titleMedium) + Text("Applies to future transcriptions. Each recording keeps the model it used.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + if (models == null) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) + Spacer(Modifier.width(10.dp)) + Text("Loading models…") + } + } else { + if (!models.fasterWhisperInstalled) { + Text("faster-whisper is not installed on the server " + + "(pip install shonar-backend[faster-whisper]).", + color = MaterialTheme.colorScheme.error) + } + models.models.forEach { m -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + (if (m.isDefault) "◉ " else "○ ") + m.displayName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + if (!m.isDefault) { + TextButton({ state.setDefaultModel(m.name) }) { + Text("Set default") + } + } + if (!m.downloaded) { + TextButton({ state.downloadModel(m.name) }) { + Text("Download") + } + } + } + Text(m.description, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("${m.params} • ${m.approxMemory} • ${m.relativeSpeed}" + + (if (m.available) " • ready" else " • not downloaded"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + modelsError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } +} diff --git a/desktop/app/src/main/kotlin/com/shonar/settings/DesktopSettingsStore.kt b/desktop/app/src/main/kotlin/com/shonar/settings/DesktopSettingsStore.kt new file mode 100644 index 0000000..7b67c49 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/shonar/settings/DesktopSettingsStore.kt @@ -0,0 +1,101 @@ +package com.shonar.settings + +import java.io.File +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.json.JSONObject + +/** + * Desktop copy of the settings-store contract. The Android original + * (DataStore/Keystore implementations) cannot compile on JVM desktop, so + * this file provides the same fully-qualified names backed by plain files: + * [SettingsStore], [InMemorySettingsStore], and [FileSettingsStore]. + * + * Secrets note: desktop v1 keeps server tokens in a 0600 JSON file next to + * ordinary settings. Good enough for a local single-user app; a keyring + * backend can replace [FileSettingsStore] later without touching callers. + */ +interface SettingsStore { + suspend fun getString(key: String): String? + suspend fun putString(key: String, value: String) + suspend fun remove(key: String) + suspend fun keys(): Set + + /** Emits whenever any value changes (for reactive UI). */ + val changes: StateFlow +} + +/** In-memory store: used by unit tests. */ +class InMemorySettingsStore : SettingsStore { + private val map = mutableMapOf() + private val changeFlow = MutableStateFlow(0L) + override val changes: StateFlow = changeFlow.asStateFlow() + + override suspend fun getString(key: String): String? = map[key] + override suspend fun putString(key: String, value: String) { + map[key] = value + changeFlow.value += 1 + } + + override suspend fun remove(key: String) { + map.remove(key) + changeFlow.value += 1 + } + + override suspend fun keys(): Set = map.keys.toSet() +} + +/** JSON file store (0600). Keys map 1:1 to the JSON object members. */ +class FileSettingsStore(private val file: File) : SettingsStore { + private val mutex = Mutex() + private val changeFlow = MutableStateFlow(0L) + override val changes: StateFlow = changeFlow.asStateFlow() + + private fun readAll(): MutableMap { + if (!file.isFile) return mutableMapOf() + return runCatching { + val obj = JSONObject(file.readText()) + buildMap { + for (key in obj.keys()) put(key, obj.optString(key, "")) + }.toMutableMap() + }.getOrDefault(mutableMapOf()) + } + + private fun writeAll(map: Map) { + file.parentFile?.mkdirs() + file.writeText(JSONObject(map).toString()) + runCatching { + file.setReadable(false, false) + file.setReadable(true, true) + file.setWritable(false, false) + file.setWritable(true, true) + } + } + + override suspend fun getString(key: String): String? = mutex.withLock { + readAll()[key] + } + + override suspend fun putString(key: String, value: String) = mutex.withLock { + val map = readAll() + map[key] = value + writeAll(map) + changeFlow.value += 1 + } + + override suspend fun remove(key: String) { + mutex.withLock { + val map = readAll() + map.remove(key) + writeAll(map) + changeFlow.value += 1 + } + } + + override suspend fun keys(): Set = mutex.withLock { + readAll().keys.toSet() + } +} diff --git a/desktop/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt b/desktop/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt new file mode 100644 index 0000000..a3e837b --- /dev/null +++ b/desktop/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt @@ -0,0 +1,60 @@ +package com.shonar.desktop + +import com.shonar.recording.SummaryData +import com.shonar.recording.TranscriptData +import com.shonar.recording.TranscriptSegment +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReportsTest { + + @Test fun renderTranscriptWithTimestamps() { + val t = TranscriptData( + version = 1, provider = "faster_whisper", model = "base", + text = "hello world", + segments = listOf( + TranscriptSegment(0.0, 1.5, "hello"), + TranscriptSegment(65.0, 67.0, "world"), + ), + ) + val md = renderReport("Evening notes", t, null) + assertTrue(md.startsWith("# Evening notes")) + assertTrue("[00:00:00] hello" in md) + assertTrue("[00:01:05] world" in md) + assertTrue("faster_whisper, v1" in md) + } + + @Test fun renderEmptyTranscript() { + val md = renderReport("x", null, null) + assertTrue("_No transcript available._" in md) + } + + @Test fun fmtTsShape() { + assertEquals("00:00:00", fmtTs(0.0)) + assertEquals("00:01:05", fmtTs(65.0)) + assertEquals("01:02:03", fmtTs(3723.0)) + } + + @Test fun reportFileName() { + val dir = Files.createTempDirectory("rep").toFile() + try { + val audio = File(dir, "chat.m4a") + assertEquals("chat.transcript.md", DesktopState.reportFile(audio).name) + } finally { + dir.deleteRecursively() + } + } + + @Test fun sharedParsersWorkOnDesktop() { + val raw = """{"version":2,"provider":"user","text":"hi","segments":[], + "edited_by_user":true}""" + val t = com.shonar.recording.parseTranscript(raw)!! + assertEquals(2, t.version) + assertTrue(t.editedByUser) + val summary = SummaryData(version = 1, content = mapOf("short" to "s")) + assertEquals("s", summary.short) + } +} diff --git a/desktop/gradle/wrapper/gradle-wrapper.jar b/desktop/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/desktop/gradle/wrapper/gradle-wrapper.jar differ diff --git a/desktop/gradle/wrapper/gradle-wrapper.properties b/desktop/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/desktop/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/desktop/gradlew b/desktop/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/desktop/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/desktop/launch-desktop.sh b/desktop/launch-desktop.sh new file mode 100755 index 0000000..1e74196 --- /dev/null +++ b/desktop/launch-desktop.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Launch the SHONAR desktop app (packaged binary, no Gradle needed). +set -euo pipefail +cd "$(dirname "$0")" +BIN="app/build/compose/binaries/main/app/shonar-desktop/bin/shonar-desktop" +if [ ! -x "$BIN" ]; then + echo "Desktop build not found. Run: ./gradlew :app:createDistributable" >&2 + exit 1 +fi +export DISPLAY="${DISPLAY:-:0}" +exec "$BIN" diff --git a/desktop/settings.gradle.kts b/desktop/settings.gradle.kts new file mode 100644 index 0000000..1add82b --- /dev/null +++ b/desktop/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + google() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } +} + +rootProject.name = "shonar-desktop" +include(":app") diff --git a/scripts/transcribe_folder.py b/scripts/transcribe_folder.py new file mode 100644 index 0000000..e4bf704 --- /dev/null +++ b/scripts/transcribe_folder.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Transcribe a folder of audio files with the local SHONAR stack. + +Usage: + python3 scripts/transcribe_folder.py /home/avi/Music/Recordings [--api URL] [--email E] [--password P] + +For each recognised audio file it: registers/logs in, uploads chunked, +finalizes, waits for the worker's transcription job, then writes a +Plaud-style `{stem}.transcript.md` (summary section included only when an +LLM provider is configured server-side) next to the audio. Files that +already have a report are skipped unless --redo is passed. + +Needs: API + worker running against the same DB, e.g. + cd backend && SHONAR_SECRET_KEY=... SHONAR_TRANSCRIPTION_PROVIDER=faster_whisper \ + SHONAR_TRANSCRIPTION_MODEL=base .venv/bin/uvicorn shonar.main:app --port 8000 + cd backend && .venv/bin/arq shonar.worker.WorkerSettings +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +CHUNK = 8 * 1024 * 1024 +MIME_BY_SUFFIX = { + ".m4a": "audio/mp4", + ".mp4": "audio/mp4", + ".aac": "audio/aac", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", + ".webm": "audio/webm", + ".mp3": "audio/mpeg", +} + + +class Api: + def __init__(self, base: str, token: str = ""): + self.base = base.rstrip("/") + self.token = token + self.login_fn = None # set by main: () -> fresh access token + + def _req(self, method: str, path: str, body: bytes | None = None, + ctype: str = "application/json", extra: dict | None = None, + _retried: bool = False): + req = urllib.request.Request(self.base + path, data=body, method=method) + if body is not None: + req.add_header("Content-Type", ctype) + if self.token: + req.add_header("Authorization", f"Bearer {self.token}") + for k, v in (extra or {}).items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=300) as r: + raw = r.read() + return r.status, json.loads(raw) if raw else None + except urllib.error.HTTPError as e: + if e.code == 401 and self.login_fn is not None and not _retried: + self.token = self.login_fn() # access tokens expire; log back in + return self._req(method, path, body, ctype, extra, _retried=True) + try: + detail = e.read().decode()[:300] + except Exception: + detail = "" + raise RuntimeError(f"{method} {path} -> HTTP {e.code} {detail}") from None + + def post_json(self, path, obj): + return self._req("POST", path, json.dumps(obj).encode()) + + def get(self, path): + return self._req("GET", path) + + +def ensure_login(api_base: str, email: str, password: str) -> str: + api = Api(api_base) + try: + status, body = api.post_json("/api/v1/auth/register", + {"email": email, "password": password}) + assert status == 201 + return body["access_token"] + except RuntimeError as e: + if "HTTP 409" not in str(e) and "HTTP 400" not in str(e): + raise + status, body = api.post_json("/api/v1/auth/login", + {"email": email, "password": password, + "device_name": "folder-script", "platform": "desktop"}) + assert status == 200, body + return body["access_token"] + + +def upload_file(api: Api, path: Path) -> str: + size = path.stat().st_size + mime = MIME_BY_SUFFIX[path.suffix.lower()] + client_id = "folder-" + hashlib.sha1(str(path).encode()).hexdigest()[:40] + status, sess = api.post_json("/api/v1/uploads", { + "declared_mime_type": mime, "declared_size_bytes": size, + "title": path.stem, "client_recording_id": client_id, + }) + assert status == 201, sess + sid, chunk_size = sess["id"], sess["chunk_size_bytes"] + data = path.read_bytes() + n = (size + chunk_size - 1) // chunk_size + for i in range(n): + part = data[i * chunk_size:(i + 1) * chunk_size] + digest = hashlib.sha256(part).hexdigest() + api._req("PUT", f"/api/v1/uploads/{sid}/chunks/{i}", part, + ctype="application/octet-stream", extra={"X-Chunk-Sha256": digest}) + status, rec = api.post_json(f"/api/v1/uploads/{sid}/finalize", + {"duration_seconds": 0.0}) + assert status == 201, rec + return rec["id"] + + +def wait_transcribed(api: Api, rid: str, timeout_s: int) -> str: + """Poll jobs until transcribe reaches a terminal state; return recording status.""" + deadline = time.time() + timeout_s + while time.time() < deadline: + status, jobs = api.get(f"/api/v1/recordings/{rid}/jobs") + by_type = {j["job_type"]: j["status"] for j in jobs} if isinstance(jobs, list) else {} + t = by_type.get("transcribe") + if t in ("succeeded", "failed", "skipped"): + status, rec = api.get(f"/api/v1/recordings/{rid}") + return rec["processing_status"] + time.sleep(5) + return "timeout" + + +def fetch_optional(api: Api, path: str): + try: + status, body = api.get(path) + return body if status == 200 else None + except RuntimeError as e: + if "HTTP 404" in str(e): + return None + raise + + +def fmt_ts(sec: float) -> str: + sec = max(0, int(sec)) + return f"{sec // 3600:02d}:{(sec % 3600) // 60:02d}:{sec % 60:02d}" + + +def render_report(title: str, transcript: dict | None, summary: dict | None) -> str: + out = [f"# {title}", ""] + if summary and isinstance(summary.get("content"), dict): + c = summary["content"] + if c.get("short"): + out += ["## Summary", "", str(c["short"]), ""] + for key in ("key_points", "decisions", "action_items", "questions"): + items = c.get(key) or [] + if items: + out += ["## " + key.replace("_", " ").title(), ""] + out += [f"- {i}" for i in items] + [""] + if c.get("detailed"): + out += ["## Details", "", str(c["detailed"]), ""] + if transcript: + out += ["## Transcript", ""] + segs = transcript.get("segments") or [] + if segs: + for s in segs: + try: + out.append(f"[{fmt_ts(float(s.get('start', 0)))}] {s.get('text', '')}") + except (TypeError, ValueError): + continue + out.append("") + else: + out += [transcript.get("text", ""), ""] + prov = transcript.get("provider") or "?" + out.append(f"*Transcribed with {prov}, v{transcript.get('version', '?')}.*") + else: + out += ["## Transcript", "", "_No transcript available._", ""] + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("folder", help="directory of audio files") + ap.add_argument("--api", default="http://localhost:8000") + ap.add_argument("--email", default="folder@example.com") + ap.add_argument("--password", default="folder-test-passw0rd-123") + ap.add_argument("--timeout", type=int, default=1800, + help="seconds to wait per file for transcription") + ap.add_argument("--redo", action="store_true", help="re-transcribe even if report exists") + ap.add_argument("--limit", type=int, default=0, help="only process first N files (0 = all)") + args = ap.parse_args() + + folder = Path(args.folder).expanduser() + if not folder.is_dir(): + print(f"error: not a directory: {folder}", file=sys.stderr) + return 2 + files = sorted(p for p in folder.iterdir() + if p.is_file() and not p.name.startswith(".") + and p.suffix.lower() in MIME_BY_SUFFIX) + # report name: ".transcript.md" next to the audio + todo = [] + for p in files: + report = p.parent / (p.stem + ".transcript.md") + if report.exists() and not args.redo: + continue + todo.append((p, report)) + if args.limit: + todo = todo[:args.limit] + if not todo: + print("nothing to do (no new audio files).") + return 0 + + print(f"logging in as {args.email} ...") + token = ensure_login(args.api, args.email, args.password) + api = Api(args.api, token) + api.login_fn = lambda: ensure_login(args.api, args.email, args.password) + + for i, (path, report) in enumerate(todo, 1): + print(f"[{i}/{len(todo)}] {path.name} ({path.stat().st_size / 1e6:.1f} MB) ...", + flush=True) + try: + rid = upload_file(api, path) + state = wait_transcribed(api, rid, args.timeout) + tr = fetch_optional(api, f"/api/v1/recordings/{rid}/transcript") + su = fetch_optional(api, f"/api/v1/recordings/{rid}/summary") + report.write_text(render_report(path.stem, tr, su)) + print(f" status={state} -> {report.name}") + except Exception as e: + print(f" FAILED: {e}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())