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:
parent
8873c759e1
commit
ee12fce5f6
14 changed files with 1746 additions and 0 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
91
desktop/app/build.gradle.kts
Normal file
91
desktop/app/build.gradle.kts
Normal 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()
|
||||
}
|
||||
515
desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt
Normal file
515
desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt
Normal 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)
|
||||
38
desktop/app/src/main/kotlin/com/shonar/desktop/Main.kt
Normal file
38
desktop/app/src/main/kotlin/com/shonar/desktop/Main.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
desktop/app/src/main/kotlin/com/shonar/desktop/Reports.kt
Normal file
48
desktop/app/src/main/kotlin/com/shonar/desktop/Reports.kt
Normal 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)
|
||||
}
|
||||
367
desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt
Normal file
367
desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
BIN
desktop/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
desktop/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
desktop/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
desktop/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
desktop/gradlew
vendored
Executable 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
11
desktop/launch-desktop.sh
Executable 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"
|
||||
17
desktop/settings.gradle.kts
Normal file
17
desktop/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "shonar-desktop"
|
||||
include(":app")
|
||||
234
scripts/transcribe_folder.py
Normal file
234
scripts/transcribe_folder.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue