Desktop: Compose-for-Desktop app (Stage 3) + folder transcribe script

- desktop/ module compiles the Android app's portable sources directly
  (shared CustomShonarProvider, parsers, folder logic — no copies);
  JVM settings store (0600 JSON) replaces DataStore/Keystore
- Engine screen: spawns the local backend+worker on demand, health-polls,
  auto-provisions a private local account (no login UI)
- Library: folder picker, audio list, report-written badges, search across
  .transcript.md reports; Detail: transcribe with per-file model override,
  job polling, Plaud-style report next to the audio; Settings: model
  registry (default, per-model download)
- scripts/transcribe_folder.py: batch-transcribe a folder via the API
- Reports + shared-parser unit tests green (gradlew test)
This commit is contained in:
avi 2026-09-12 22:33:24 -05:00
commit ee12fce5f6
14 changed files with 1746 additions and 0 deletions

6
.gitignore vendored
View file

@ -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/

View file

@ -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<Test> {
useJUnit()
}

View file

@ -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<JobInfo> = 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> = _screen.asStateFlow()
private val _serverUrl = MutableStateFlow("http://localhost:8000")
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _engine = MutableStateFlow(EngineState.UNKNOWN)
val engine: StateFlow<EngineState> = _engine.asStateFlow()
private val _engineError = MutableStateFlow<String?>(null)
val engineError: StateFlow<String?> = _engineError.asStateFlow()
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
private val _folder = MutableStateFlow<File?>(null)
val folder: StateFlow<File?> = _folder.asStateFlow()
private val _entries = MutableStateFlow<List<LibraryEntry>>(emptyList())
val entries: StateFlow<List<LibraryEntry>> = _entries.asStateFlow()
private val _models = MutableStateFlow<ModelsData?>(null)
val models: StateFlow<ModelsData?> = _models.asStateFlow()
private val _modelsError = MutableStateFlow<String?>(null)
val modelsError: StateFlow<String?> = _modelsError.asStateFlow()
private val _detail = MutableStateFlow<DetailUi?>(null)
val detail: StateFlow<DetailUi?> = _detail.asStateFlow()
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _searchResults = MutableStateFlow<List<SearchHit>>(emptyList())
val searchResults: StateFlow<List<SearchHit>> = _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<String>, env: Map<String, String>): 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<SearchHit>()
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)

View file

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

View file

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

View file

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

View file

@ -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<String>
/** Emits whenever any value changes (for reactive UI). */
val changes: StateFlow<Long>
}
/** In-memory store: used by unit tests. */
class InMemorySettingsStore : SettingsStore {
private val map = mutableMapOf<String, String>()
private val changeFlow = MutableStateFlow(0L)
override val changes: StateFlow<Long> = 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<String> = 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<Long> = changeFlow.asStateFlow()
private fun readAll(): MutableMap<String, String> {
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<String, String>) {
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<String> = mutex.withLock {
readAll().keys.toSet()
}
}

View file

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

Binary file not shown.

View file

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

251
desktop/gradlew vendored Executable file
View file

@ -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" "$@"

11
desktop/launch-desktop.sh Executable file
View file

@ -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"

View file

@ -0,0 +1,17 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
google()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
google()
}
}
rootProject.name = "shonar-desktop"
include(":app")

View file

@ -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 && <same env> .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: "<stem>.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())