vela: modo reunión — transcripción offline por fragmento (Vosk)
Nuevo Modo.REUNION que transcribe lo hablado sin tocar la liviandad de la noche: vigilia y grabación normal no ejecutan nada de STT (transcriptor solo se crea en reunión). - audio/Transcriptor: Vosk offline en hilo propio (cola productor/ consumidor) alimentado del mismo PCM que ya captura la Grabadora, así el reconocimiento pesado no retrasa el hilo de audio ni ensucia el .m4a. El reconocedor se reinicia por fragmento → cada fila del log lleva su texto. - modelo (~45 MB) no va en el APK: se descarga y descomprime la 1ª vez (ModeloScreen con progreso); luego todo offline. Reunión fuerza 16 kHz. - Segmento gana texto (persistido en log.json como "txt"); mutaciones de segmentos sincronizadas entre el hilo de captura y el de Vosk. - UI: 3ª acción "reunión", texto en vivo en GrabandoScreen, texto por fragmento en el log, distinción de color (rosa) en las listas. - dep nueva libs.vosk.android (solo :vela); permiso INTERNET para bajar el modelo. Formato de audio se queda en .m4a (AAC) por decisión del usuario. NO compilado aquí (sin SDK en este equipo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
15 archivos · +476 −55
- gradle/libs.versions.toml +3 −0
- vela/build.gradle.kts +3 −0
- vela/src/main/AndroidManifest.xml +4 −0
- vela/src/main/java/com/rizoma/vela/VelaViewModel.kt +31 −0
- vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt +72 −18
- vela/src/main/java/com/rizoma/vela/audio/Transcriptor.kt +171 −0
- vela/src/main/java/com/rizoma/vela/data/Repositorio.kt +2 −0
- vela/src/main/java/com/rizoma/vela/data/Sesion.kt +9 −2
- vela/src/main/java/com/rizoma/vela/service/VigiliaService.kt +7 −3
- vela/src/main/java/com/rizoma/vela/ui/GrabandoScreen.kt +21 −5
- vela/src/main/java/com/rizoma/vela/ui/ModeloScreen.kt +94 −0
- vela/src/main/java/com/rizoma/vela/ui/RegistroDialogo.kt +26 −21
- vela/src/main/java/com/rizoma/vela/ui/SesionScreen.kt +12 −3
- vela/src/main/java/com/rizoma/vela/ui/SesionesScreen.kt +12 −3
- vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt +9 −0
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7733bd4..38327cb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,7 @@ composeBom = "2025.06.01"
datastore = "1.1.7"
coroutines = "1.10.2"
okhttp = "4.12.0"
+vosk = "0.3.47"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,6 +25,8 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+# Reconocimiento de voz offline (solo lo usa :vela en modo reunión).
+vosk-android = { group = "com.alphacephei", name = "vosk-android", version.ref = "vosk" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/vela/build.gradle.kts b/vela/build.gradle.kts
index 2725eb3..e7c4b9c 100644
--- a/vela/build.gradle.kts
+++ b/vela/build.gradle.kts
@@ -47,4 +47,7 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.coroutines.android)
+ // Transcripción offline (modo reunión). Trae libs nativas + el modelo se
+ // descarga en el 1er uso, no va en el APK.
+ implementation(libs.vosk.android)
}
diff --git a/vela/src/main/AndroidManifest.xml b/vela/src/main/AndroidManifest.xml
index 611b1a1..e466147 100644
--- a/vela/src/main/AndroidManifest.xml
+++ b/vela/src/main/AndroidManifest.xml
@@ -4,6 +4,10 @@
<!-- Escucha el micrófono para grabar y detectar voz/sonido. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <!-- Solo para descargar el modelo de voz (modo reunión) la primera vez. -->
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
<!-- Servicio de primer plano: sigue grabando/velando con la pantalla
apagada. Tipo micrófono (obligatorio en Android 14+ para usar el mic
desde un servicio). -->
diff --git a/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt b/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt
index f882075..027baf5 100644
--- a/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt
+++ b/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rizoma.vela.audio.GrabadoraHolder
+import com.rizoma.vela.audio.Transcriptor
import com.rizoma.vela.data.Ajustes
import com.rizoma.vela.data.AjustesActuales
import com.rizoma.vela.data.Modo
@@ -28,6 +29,7 @@ sealed interface Pantalla {
data object Grabando : Pantalla
data class Ver(val id: Long) : Pantalla
data object Ajustes : Pantalla
+ data object Modelo : Pantalla
}
class VelaViewModel(app: Application) : AndroidViewModel(app) {
@@ -41,6 +43,11 @@ class VelaViewModel(app: Application) : AndroidViewModel(app) {
var sesiones by mutableStateOf<List<Sesion>>(emptyList()); private set
var cfg by mutableStateOf(AjustesActuales()); private set
+ // Descarga del modelo de voz (modo reunión), la 1ª vez.
+ var modeloDescargando by mutableStateOf(false); private set
+ var modeloProgreso by mutableStateOf(0f); private set
+ var modeloError by mutableStateOf<String?>(null); private set
+
private var servicio: VigiliaService? by mutableStateOf(null)
val holder: GrabadoraHolder? get() = servicio?.holder
@@ -83,6 +90,12 @@ class VelaViewModel(app: Application) : AndroidViewModel(app) {
errorMic = null
if (!permiso) { errorMic = "hace falta permiso de micrófono"; return }
val app = getApplication<Application>()
+ // Reunión necesita el modelo de voz: si no está, primero a descargarlo.
+ if (modo == Modo.REUNION && !Transcriptor.modeloPresente(app)) {
+ modeloError = null
+ pantalla = Pantalla.Modelo
+ return
+ }
app.startForegroundService(Intent(app, VigiliaService::class.java))
val svc = servicio
if (svc != null) arrancar(modo) else modoPendiente = modo
@@ -106,6 +119,24 @@ class VelaViewModel(app: Application) : AndroidViewModel(app) {
pantalla = if (id != 0L) Pantalla.Ver(id) else Pantalla.Sesiones
}
+ // ---- Modelo de voz ------------------------------------------------
+
+ fun descargarModelo() {
+ if (modeloDescargando) return
+ modeloDescargando = true
+ modeloProgreso = 0f
+ modeloError = null
+ viewModelScope.launch {
+ val ok = withContext(Dispatchers.IO) {
+ runCatching {
+ Transcriptor.descargar(getApplication()) { p -> modeloProgreso = p }
+ }.isSuccess
+ }
+ modeloDescargando = false
+ if (ok) grabar(Modo.REUNION) else modeloError = "no se pudo descargar el modelo"
+ }
+ }
+
// ---- Navegación ---------------------------------------------------
fun abrir(id: Long) { pantalla = Pantalla.Ver(id) }
diff --git a/vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt b/vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt
index 11fcd22..024ff06 100644
--- a/vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt
+++ b/vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt
@@ -45,6 +45,9 @@ class GrabadoraHolder {
var duracionRealMs by mutableStateOf(0L); internal set
var duracionSonoraMs by mutableStateOf(0L); internal set
+ /** Texto en curso del fragmento activo (modo reunión). */
+ var parcial by mutableStateOf(""); internal set
+
/** Fragmentos de la sesión en curso (crece en vivo). */
val segmentos: SnapshotStateList<Segmento> = mutableStateListOf()
@@ -58,6 +61,7 @@ class GrabadoraHolder {
ondasVer = 0
nivelDb = -70f; picoDb = -70f
duracionRealMs = 0L; duracionSonoraMs = 0L
+ parcial = ""
}
companion object { const val ANCHO_ONDA = 160 }
@@ -70,36 +74,47 @@ class GrabadoraHolder {
* palabras y con cola no corta el final. En silencio solo calcula RMS: barato,
* ideal para velar de noche ahorrando batería.
*
+ * En modo reunión bifurca el mismo PCM hacia un [Transcriptor] (Vosk, offline)
+ * que corre en su propio hilo, sin retrasar la captura.
+ *
* Toda la captura corre en un hilo propio; el estado se publica al hilo
* principal para que Compose lo lea.
*/
class Grabadora(
private val context: Context,
private val repo: Repositorio,
- private val ajustes: AjustesActuales,
+ ajustes: AjustesActuales,
val holder: GrabadoraHolder,
+ private val modo: Modo,
/** Se llama (en el hilo principal) cuando se cierra un fragmento. */
private val onSegmento: (Int) -> Unit = {},
) {
private val principal = Handler(Looper.getMainLooper())
- private val sampleRate = ajustes.sampleRate
+ // Reunión fuerza 16 kHz: es lo que espera el modelo de voz y sobra para hablar.
+ private val sampleRate = if (modo == Modo.REUNION) 16_000 else ajustes.sampleRate
private val frame = sampleRate / 50 // 20 ms por cuadro
private val preRollCuadros = (ajustes.preRollMs / 20).coerceAtLeast(0)
private val colaCuadros = (ajustes.colaMs / 20).coerceAtLeast(0)
private var record: AudioRecord? = null
private var codificador: CodificadorAac? = null
+ private var transcriptor: Transcriptor? = null
private var hilo: Thread? = null
@Volatile private var corriendo = false
private val puerta = Puerta(ajustes.sensibilidad)
+
+ // Lista canónica de fragmentos; la mutan el hilo de captura y el de Vosk.
+ private val lock = Any()
private val segmentos = ArrayList<Segmento>()
+ @Volatile private var lastReal = 0L
+ @Volatile private var lastSonora = 0L
private var id = 0L
private var inicioEpoch = 0L
/** ¿Se pudo abrir el micrófono? Devuelve false si faltó permiso/hardware. */
- fun iniciar(modo: Modo): Boolean {
+ fun iniciar(): Boolean {
if (corriendo) return true
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
@@ -130,7 +145,15 @@ class Grabadora(
val bitRate = if (sampleRate <= 16_000) 32_000 else 64_000
codificador = CodificadorAac(sampleRate, bitRate, java.io.File(dir, "audio.m4a"))
record = rec
- segmentos.clear()
+ synchronized(lock) { segmentos.clear() }
+
+ if (modo == Modo.REUNION) {
+ transcriptor = Transcriptor.crear(
+ context,
+ onParcial = { _, txt -> principal.post { holder.parcial = txt } },
+ onFinal = { idx, txt -> ponerTexto(idx, txt) },
+ )
+ }
rec.startRecording()
corriendo = true
@@ -142,7 +165,7 @@ class Grabadora(
holder.activa = true
holder.estado = EstadoGrabadora.ESCUCHANDO
- hilo = Thread({ bucle(modo) }, "vela-captura").apply { start() }
+ hilo = Thread({ bucle() }, "vela-captura").apply { start() }
return true
}
@@ -151,18 +174,22 @@ class Grabadora(
corriendo = false
hilo?.join(2_000)
hilo = null
+ transcriptor?.detener()
+ transcriptor = null
}
// ---- Bucle de captura (hilo propio) -------------------------------
- private fun bucle(modo: Modo) {
+ private fun bucle() {
val rec = record ?: return
val cod = codificador ?: return
+ val tr = transcriptor
val buf = ShortArray(frame)
val ring = ArrayDeque<ShortArray>() // pre-roll: últimos cuadros de silencio
var enSonido = false
var colaRestante = 0
+ var segIdx = 0
var segInicioReal = 0L
var segArchivoUs = 0L
var segPico = -120f
@@ -183,24 +210,29 @@ class Grabadora(
// Abrir fragmento: fija tiempos y vuelca el pre-roll (el
// arranque de la palabra que estaba en el ring).
enSonido = true
+ segIdx = indice++
holder.estado = EstadoGrabadora.GRABANDO
+ principal.post { holder.parcial = "" }
segInicioReal = System.currentTimeMillis() - inicioEpoch
segArchivoUs = cod.currentFileUs()
segPico = -120f; segSuma = 0f; segFrames = 0
+ tr?.abrir(segIdx)
while (ring.isNotEmpty()) {
- val f = ring.removeFirst(); cod.feed(f, f.size)
+ val f = ring.removeFirst()
+ cod.feed(f, f.size); tr?.alimentar(f, f.size, segIdx)
}
}
} else if (enSonido) {
if (colaRestante > 0) colaRestante-- else {
- cerrarSegmento(cod, indice++, segInicioReal, segArchivoUs, segPico, segSuma, segFrames)
+ cerrarSegmento(cod, segIdx, segInicioReal, segArchivoUs, segPico, segSuma, segFrames)
+ tr?.cerrar(segIdx)
enSonido = false
holder.estado = EstadoGrabadora.ESCUCHANDO
}
}
if (enSonido) {
- cod.feed(buf, n)
+ cod.feed(buf, n); tr?.alimentar(buf, n, segIdx)
segFrames++; segSuma += db; if (db > segPico) segPico = db
} else {
ring.addLast(buf.copyOf(n))
@@ -214,7 +246,8 @@ class Grabadora(
// Cierre: fragmento abierto + EOS del codificador + log final.
if (enSonido) {
- cerrarSegmento(cod, indice, segInicioReal, segArchivoUs, segPico, segSuma, segFrames)
+ cerrarSegmento(cod, segIdx, segInicioReal, segArchivoUs, segPico, segSuma, segFrames)
+ tr?.cerrar(segIdx)
}
cod.finish()
runCatching { rec.stop() }
@@ -224,10 +257,12 @@ class Grabadora(
val real = System.currentTimeMillis() - inicioEpoch
val sonora = cod.currentFileUs() / 1000
- repo.escribirLog(id, inicioEpoch, sampleRate, modo, real, sonora, segmentos.toList())
+ lastReal = real; lastSonora = sonora
+ synchronized(lock) { guardarLog() }
principal.post {
holder.duracionRealMs = real
holder.duracionSonoraMs = sonora
+ holder.parcial = ""
holder.activa = false
holder.estado = EstadoGrabadora.INACTIVA
}
@@ -240,19 +275,38 @@ class Grabadora(
val durMs = (cod.currentFileUs() - archivoUs) / 1000
val medio = if (frames > 0) suma / frames else -70f
val seg = Segmento(idx, inicioReal, durMs, archivoUs / 1000, pico, medio)
- segmentos.add(seg)
- repo.escribirLog(
- id, inicioEpoch, sampleRate, holder.modo,
- System.currentTimeMillis() - inicioEpoch, cod.currentFileUs() / 1000,
- segmentos.toList(),
- )
+ lastReal = System.currentTimeMillis() - inicioEpoch
+ lastSonora = cod.currentFileUs() / 1000
+ synchronized(lock) {
+ segmentos.add(seg)
+ guardarLog()
+ }
principal.post {
holder.segmentos.add(seg)
- holder.duracionSonoraMs = cod.currentFileUs() / 1000
+ holder.duracionSonoraMs = lastSonora
onSegmento(segmentos.size)
}
}
+ /** El transcriptor (otro hilo) entrega el texto de un fragmento ya cerrado. */
+ private fun ponerTexto(idx: Int, texto: String) {
+ val nuevo: Segmento
+ synchronized(lock) {
+ if (idx >= segmentos.size) return
+ nuevo = segmentos[idx].copy(texto = texto.ifBlank { null })
+ segmentos[idx] = nuevo
+ guardarLog()
+ }
+ principal.post {
+ if (idx < holder.segmentos.size) holder.segmentos[idx] = nuevo
+ }
+ }
+
+ /** Reescribe el log completo. Debe llamarse dentro de synchronized(lock). */
+ private fun guardarLog() {
+ repo.escribirLog(id, inicioEpoch, sampleRate, modo, lastReal, lastSonora, ArrayList(segmentos))
+ }
+
private fun publicarMedidor(db: Float, fileUs: Long) {
val umbral = puerta.umbralDb
val real = System.currentTimeMillis() - inicioEpoch
diff --git a/vela/src/main/java/com/rizoma/vela/audio/Transcriptor.kt b/vela/src/main/java/com/rizoma/vela/audio/Transcriptor.kt
new file mode 100644
index 0000000..fcb60bb
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/audio/Transcriptor.kt
@@ -0,0 +1,171 @@
+package com.rizoma.vela.audio
+
+import android.content.Context
+import org.json.JSONObject
+import org.vosk.LogLevel
+import org.vosk.LibVosk
+import org.vosk.Model
+import org.vosk.Recognizer
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.zip.ZipInputStream
+
+/**
+ * Transcripción offline con Vosk, **solo para el modo reunión**. Se alimenta
+ * del mismo PCM que ya captura la [Grabadora], pero el reconocimiento pesado
+ * corre en un **hilo propio** (cola productor/consumidor) para no retrasar el
+ * hilo de audio en tiempo real ni ensuciar la grabación.
+ *
+ * El reconocedor se reinicia por fragmento: cada fragmento del log recibe su
+ * propio texto, alineado con la línea de tiempo.
+ *
+ * El modelo (~45 MB) NO va en el APK: se descarga y descomprime la primera vez
+ * ([descargar]); a partir de ahí todo es offline.
+ */
+class Transcriptor private constructor(
+ private val modelo: Model,
+ /** Texto en curso del fragmento (para mostrar en vivo). */
+ private val onParcial: (Int, String) -> Unit,
+ /** Texto final de un fragmento ya cerrado. */
+ private val onFinal: (Int, String) -> Unit,
+) {
+ private val recognizer = Recognizer(modelo, 16_000.0f)
+ private val cola = LinkedBlockingQueue<Tarea>()
+ private val worker = Thread({ bucle() }, "vela-vosk").apply { start() }
+
+ private sealed interface Tarea {
+ data class Abrir(val idx: Int) : Tarea
+ class Audio(val pcm: ShortArray, val n: Int, val idx: Int) : Tarea
+ data class Cerrar(val idx: Int) : Tarea
+ data object Fin : Tarea
+ }
+
+ fun abrir(idx: Int) { cola.put(Tarea.Abrir(idx)) }
+ fun alimentar(pcm: ShortArray, n: Int, idx: Int) { cola.put(Tarea.Audio(pcm.copyOf(n), n, idx)) }
+ fun cerrar(idx: Int) { cola.put(Tarea.Cerrar(idx)) }
+
+ /** Vacía la cola pendiente, cierra Vosk y suelta el modelo. */
+ fun detener() {
+ cola.put(Tarea.Fin)
+ worker.join(4_000)
+ }
+
+ private fun bucle() {
+ var acc = StringBuilder()
+ var cuadros = 0
+ while (true) {
+ when (val t = cola.take()) {
+ is Tarea.Abrir -> {
+ recognizer.reset(); acc = StringBuilder(); cuadros = 0
+ }
+
+ is Tarea.Audio -> {
+ val fin = recognizer.acceptWaveform(t.pcm, t.n)
+ if (fin) acc.append(textoDe(recognizer.getResult(), "text")).append(' ')
+ // Parcial en vivo cada ~5 cuadros (100 ms) para no saturar.
+ if (cuadros++ % 5 == 0) {
+ val vivo = (acc.toString() + textoDe(recognizer.getPartialResult(), "partial")).trim()
+ onParcial(t.idx, vivo)
+ }
+ }
+
+ is Tarea.Cerrar -> {
+ acc.append(textoDe(recognizer.getFinalResult(), "text"))
+ onFinal(t.idx, acc.toString().trim())
+ acc = StringBuilder()
+ }
+
+ Tarea.Fin -> break
+ }
+ }
+ runCatching { recognizer.close() }
+ runCatching { modelo.close() }
+ }
+
+ private fun textoDe(json: String, clave: String): String =
+ runCatching { JSONObject(json).optString(clave, "") }.getOrDefault("")
+
+ companion object {
+ private const val URL_MODELO =
+ "https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip"
+ private const val CARPETA = "vosk-model-small-es-0.42"
+
+ private fun base(context: Context) = File(context.filesDir, "vosk")
+ private fun ruta(context: Context) = File(base(context), CARPETA)
+
+ /** ¿Está el modelo ya descomprimido y usable? */
+ fun modeloPresente(context: Context): Boolean =
+ File(ruta(context), "am").exists() || File(ruta(context), "conf").exists()
+
+ /**
+ * Descarga y descomprime el modelo. [onProgreso] recibe 0..1 (o −1 si
+ * no se conoce el tamaño). Bloqueante: llamar en un hilo de fondo.
+ */
+ fun descargar(context: Context, onProgreso: (Float) -> Unit) {
+ val base = base(context).apply { mkdirs() }
+ val zip = File(base, "modelo.zip.tmp")
+ val con = (URL(URL_MODELO).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 20_000; readTimeout = 30_000
+ }
+ try {
+ con.connect()
+ val total = con.contentLength.toLong()
+ var leido = 0L
+ con.inputStream.use { ent ->
+ zip.outputStream().use { sal ->
+ val buf = ByteArray(64 * 1024)
+ while (true) {
+ val n = ent.read(buf); if (n < 0) break
+ sal.write(buf, 0, n); leido += n
+ onProgreso(if (total > 0) (leido.toFloat() / total) else -1f)
+ }
+ }
+ }
+ descomprimir(zip, base)
+ } finally {
+ con.disconnect()
+ zip.delete()
+ }
+ }
+
+ private fun descomprimir(zip: File, destino: File) {
+ ZipInputStream(zip.inputStream().buffered()).use { z ->
+ var e = z.nextEntry
+ while (e != null) {
+ val f = File(destino, e.name)
+ // Defensa básica contra zip-slip.
+ if (!f.canonicalPath.startsWith(destino.canonicalPath)) {
+ e = z.nextEntry; continue
+ }
+ if (e.isDirectory) f.mkdirs() else {
+ f.parentFile?.mkdirs()
+ f.outputStream().use { z.copyTo(it) }
+ }
+ z.closeEntry()
+ e = z.nextEntry
+ }
+ }
+ }
+
+ /**
+ * Crea un transcriptor cargando el modelo del disco. Devuelve null si el
+ * modelo no está o falla la carga (la grabación sigue sin transcripción).
+ */
+ fun crear(
+ context: Context,
+ onParcial: (Int, String) -> Unit,
+ onFinal: (Int, String) -> Unit,
+ ): Transcriptor? {
+ if (!modeloPresente(context)) return null
+ return try {
+ LibVosk.setLogLevel(LogLevel.WARNINGS)
+ val modelo = Model(ruta(context).absolutePath)
+ Transcriptor(modelo, onParcial, onFinal)
+ } catch (_: Throwable) {
+ null
+ }
+ }
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/data/Repositorio.kt b/vela/src/main/java/com/rizoma/vela/data/Repositorio.kt
index 869363a..f65ca5e 100644
--- a/vela/src/main/java/com/rizoma/vela/data/Repositorio.kt
+++ b/vela/src/main/java/com/rizoma/vela/data/Repositorio.kt
@@ -42,6 +42,7 @@ class Repositorio(context: Context) {
put("arch", s.archivoMs)
put("pico", s.picoDb.toDouble())
put("medio", s.medioDb.toDouble())
+ if (!s.texto.isNullOrBlank()) put("txt", s.texto)
})
}
})
@@ -78,6 +79,7 @@ class Repositorio(context: Context) {
archivoMs = s.optLong("arch"),
picoDb = s.optDouble("pico", -70.0).toFloat(),
medioDb = s.optDouble("medio", -70.0).toFloat(),
+ texto = s.optString("txt", "").ifBlank { null },
)
)
}
diff --git a/vela/src/main/java/com/rizoma/vela/data/Sesion.kt b/vela/src/main/java/com/rizoma/vela/data/Sesion.kt
index d7450c7..6f71636 100644
--- a/vela/src/main/java/com/rizoma/vela/data/Sesion.kt
+++ b/vela/src/main/java/com/rizoma/vela/data/Sesion.kt
@@ -19,10 +19,17 @@ data class Segmento(
val picoDb: Float,
/** Nivel medio del fragmento, en dBFS. */
val medioDb: Float,
+ /** Texto transcrito del fragmento (solo modo reunión); null si no hay. */
+ val texto: String? = null,
)
-/** Modo con el que se grabó/vela una sesión. */
-enum class Modo { NORMAL, VIGILIA }
+/**
+ * Modo con el que se grabó/vela una sesión.
+ * - NORMAL: grabar ahora.
+ * - VIGILIA: monitor nocturno liviano (sin transcripción).
+ * - REUNION: transcribe cada fragmento con Vosk (offline).
+ */
+enum class Modo { NORMAL, VIGILIA, REUNION }
/**
* Una sesión = una carpeta con `audio.m4a` (audio compactado) y `log.json`
diff --git a/vela/src/main/java/com/rizoma/vela/service/VigiliaService.kt b/vela/src/main/java/com/rizoma/vela/service/VigiliaService.kt
index 251d407..462246d 100644
--- a/vela/src/main/java/com/rizoma/vela/service/VigiliaService.kt
+++ b/vela/src/main/java/com/rizoma/vela/service/VigiliaService.kt
@@ -76,11 +76,11 @@ class VigiliaService : Service() {
enterForeground(modo, 0)
adquirirWakelock()
val motor = Grabadora(
- this@VigiliaService, repo, cfg, holder,
+ this@VigiliaService, repo, cfg, holder, modo,
onSegmento = { n -> refrescarNotificacion(modo, n) },
)
grabadora = motor
- val ok = motor.iniciar(modo)
+ val ok = motor.iniciar()
if (!ok) {
grabadora = null
soltarTodo()
@@ -158,7 +158,11 @@ class VigiliaService : Service() {
Intent(this, VigiliaService::class.java).setAction(ACTION_STOP),
flags,
)
- val titulo = if (modo == Modo.VIGILIA) "[ velando la noche ]" else "[ grabando ]"
+ val titulo = when (modo) {
+ Modo.VIGILIA -> "[ velando la noche ]"
+ Modo.REUNION -> "[ reunión · transcribiendo ]"
+ else -> "[ grabando ]"
+ }
val sub = if (fragmentos > 0) "$fragmentos fragmentos con sonido" else "escuchando…"
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_vela)
diff --git a/vela/src/main/java/com/rizoma/vela/ui/GrabandoScreen.kt b/vela/src/main/java/com/rizoma/vela/ui/GrabandoScreen.kt
index 4eb5fc8..84bb11c 100644
--- a/vela/src/main/java/com/rizoma/vela/ui/GrabandoScreen.kt
+++ b/vela/src/main/java/com/rizoma/vela/ui/GrabandoScreen.kt
@@ -64,18 +64,20 @@ fun GrabandoScreen(
}
val grabando = holder.estado == EstadoGrabadora.GRABANDO
- val esVigilia = holder.modo == Modo.VIGILIA
+ val esReunion = holder.modo == Modo.REUNION
val acento = if (grabando) NeonRosa else NeonVerde
+ val titulo = when (holder.modo) {
+ Modo.VIGILIA -> "velando la noche"
+ Modo.REUNION -> "reunión · transcribiendo"
+ else -> "grabando"
+ }
Column(
Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(24.dp))
- Text(
- if (esVigilia) "velando la noche" else "grabando",
- color = Tenue, fontSize = 14.sp, letterSpacing = 3.sp,
- )
+ Text(titulo, color = Tenue, fontSize = 14.sp, letterSpacing = 3.sp)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
@@ -102,6 +104,20 @@ fun GrabandoScreen(
Metrica("fragmentos", holder.segmentos.size.toString(), NeonCian)
}
+ if (esReunion) {
+ Spacer(Modifier.height(16.dp))
+ Box(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
+ .background(Color(0x14FF2E88)).padding(14.dp),
+ ) {
+ Text(
+ holder.parcial.ifBlank { "escuchando la conversación…" },
+ color = if (holder.parcial.isBlank()) MuyTenue else Color.White,
+ fontSize = 16.sp,
+ )
+ }
+ }
+
Spacer(Modifier.height(20.dp))
Text(
"log de diálogo",
diff --git a/vela/src/main/java/com/rizoma/vela/ui/ModeloScreen.kt b/vela/src/main/java/com/rizoma/vela/ui/ModeloScreen.kt
new file mode 100644
index 0000000..3bac776
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/ModeloScreen.kt
@@ -0,0 +1,94 @@
+package com.rizoma.vela.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.vela.ui.theme.MuyTenue
+import com.rizoma.vela.ui.theme.NeonRosa
+import com.rizoma.vela.ui.theme.Superficie
+import com.rizoma.vela.ui.theme.Tenue
+import com.rizoma.vela.ui.theme.Vacio
+
+@Composable
+fun ModeloScreen(
+ descargando: Boolean,
+ progreso: Float,
+ error: String?,
+ onDescargar: () -> Unit,
+ onVolver: () -> Unit,
+) {
+ Column(Modifier.fillMaxSize().padding(24.dp)) {
+ Text(
+ "‹ volver", color = Tenue, fontSize = 15.sp,
+ modifier = Modifier.clickable(enabled = !descargando) { onVolver() }
+ .padding(top = 12.dp, bottom = 4.dp),
+ )
+ Spacer(Modifier.height(20.dp))
+ Text("modo reunión", color = NeonRosa, fontSize = 26.sp, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(12.dp))
+ Text(
+ "para transcribir sin depender de internet ni de servidores, se usa un " +
+ "modelo de voz que corre en el propio teléfono. hay que descargarlo " +
+ "una sola vez (≈45 MB). después todo es offline.",
+ color = Tenue, fontSize = 15.sp, lineHeight = 22.sp,
+ )
+
+ Spacer(Modifier.height(28.dp))
+ if (descargando) {
+ val pct = if (progreso >= 0f) "${(progreso * 100).toInt()}%" else "…"
+ Text("descargando modelo · $pct", color = Tenue, fontSize = 14.sp,
+ fontFamily = FontFamily.Monospace)
+ Spacer(Modifier.height(10.dp))
+ Box(
+ Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp))
+ .background(Color(0x14FFFFFF)),
+ ) {
+ Box(
+ Modifier.fillMaxWidth(progreso.coerceIn(0f, 1f)).height(8.dp)
+ .clip(RoundedCornerShape(4.dp)).background(NeonRosa),
+ )
+ }
+ } else {
+ Box(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp))
+ .background(Superficie)
+ .border(1.dp, NeonRosa.copy(alpha = 0.5f), RoundedCornerShape(14.dp))
+ .clickable { onDescargar() }
+ .padding(18.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("descargar modelo de voz", color = NeonRosa, fontSize = 17.sp,
+ fontWeight = FontWeight.Bold)
+ }
+ }
+
+ if (error != null) {
+ Spacer(Modifier.height(16.dp))
+ Text(error, color = NeonRosa, fontSize = 14.sp)
+ }
+
+ Spacer(Modifier.height(20.dp))
+ Text(
+ "sugerencia: hazlo con wifi.",
+ color = MuyTenue, fontSize = 12.sp,
+ )
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/RegistroDialogo.kt b/vela/src/main/java/com/rizoma/vela/ui/RegistroDialogo.kt
index a710874..1ca2a73 100644
--- a/vela/src/main/java/com/rizoma/vela/ui/RegistroDialogo.kt
+++ b/vela/src/main/java/com/rizoma/vela/ui/RegistroDialogo.kt
@@ -94,7 +94,7 @@ private fun FilaFragmento(
onTap: ((Segmento) -> Unit)?,
) {
val fondo = if (activo) Color(0x2200E5FF) else SuperficieAlta
- Row(
+ Column(
Modifier
.fillMaxWidth()
.padding(vertical = 3.dp)
@@ -102,29 +102,34 @@ private fun FilaFragmento(
.background(fondo)
.then(if (onTap != null) Modifier.clickable { onTap(s) } else Modifier)
.padding(horizontal = 12.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
) {
- Text(
- horaDe(inicioEpoch + s.inicioRealMs),
- color = if (activo) NeonCian else Tenue,
- fontSize = 13.sp, fontFamily = FontFamily.Monospace,
- )
- Spacer(Modifier.width(12.dp))
- // barra de nivel del fragmento (pico)
- Box(
- Modifier.weight(1f).height(6.dp).clip(RoundedCornerShape(3.dp))
- .background(Color(0x14FFFFFF)),
- ) {
- val f = nivelFrac(s.picoDb)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ horaDe(inicioEpoch + s.inicioRealMs),
+ color = if (activo) NeonCian else Tenue,
+ fontSize = 13.sp, fontFamily = FontFamily.Monospace,
+ )
+ Spacer(Modifier.width(12.dp))
+ // barra de nivel del fragmento (pico)
Box(
- Modifier.fillMaxWidth(f).height(6.dp).clip(RoundedCornerShape(3.dp))
- .background(if (f > 0.75f) NeonRosa else NeonVerde),
+ Modifier.weight(1f).height(6.dp).clip(RoundedCornerShape(3.dp))
+ .background(Color(0x14FFFFFF)),
+ ) {
+ val f = nivelFrac(s.picoDb)
+ Box(
+ Modifier.fillMaxWidth(f).height(6.dp).clip(RoundedCornerShape(3.dp))
+ .background(if (f > 0.75f) NeonRosa else NeonVerde),
+ )
+ }
+ Spacer(Modifier.width(12.dp))
+ Text(
+ duracion(s.duracionMs),
+ color = Tenue, fontSize = 13.sp, fontFamily = FontFamily.Monospace,
)
}
- Spacer(Modifier.width(12.dp))
- Text(
- duracion(s.duracionMs),
- color = Tenue, fontSize = 13.sp, fontFamily = FontFamily.Monospace,
- )
+ if (!s.texto.isNullOrBlank()) {
+ Spacer(Modifier.height(6.dp))
+ Text(s.texto, color = Color.White, fontSize = 14.sp)
+ }
}
}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/SesionScreen.kt b/vela/src/main/java/com/rizoma/vela/ui/SesionScreen.kt
index 32ba5f0..32ddc99 100644
--- a/vela/src/main/java/com/rizoma/vela/ui/SesionScreen.kt
+++ b/vela/src/main/java/com/rizoma/vela/ui/SesionScreen.kt
@@ -88,10 +88,19 @@ fun SesionScreen(
Spacer(Modifier.height(8.dp))
Text(fechaDe(sesion.inicioEpoch), color = Color.White, fontSize = 22.sp,
fontWeight = FontWeight.Bold)
+ val etiquetaModo = when (sesion.modo) {
+ Modo.VIGILIA -> "vigilia nocturna"
+ Modo.REUNION -> "reunión · transcrita"
+ else -> "grabación"
+ }
Text(
- (if (sesion.modo == Modo.VIGILIA) "vigilia nocturna" else "grabación") +
- " · ${sesion.sampleRate / 1000} kHz",
- color = if (sesion.modo == Modo.VIGILIA) NeonCian else NeonVerde, fontSize = 13.sp,
+ "$etiquetaModo · ${sesion.sampleRate / 1000} kHz",
+ color = when (sesion.modo) {
+ Modo.VIGILIA -> NeonCian
+ Modo.REUNION -> NeonRosa
+ else -> NeonVerde
+ },
+ fontSize = 13.sp,
)
Spacer(Modifier.height(16.dp))
diff --git a/vela/src/main/java/com/rizoma/vela/ui/SesionesScreen.kt b/vela/src/main/java/com/rizoma/vela/ui/SesionesScreen.kt
index aba0c0e..9a88605 100644
--- a/vela/src/main/java/com/rizoma/vela/ui/SesionesScreen.kt
+++ b/vela/src/main/java/com/rizoma/vela/ui/SesionesScreen.kt
@@ -43,6 +43,7 @@ fun SesionesScreen(
error: String?,
onGrabar: () -> Unit,
onVigilia: () -> Unit,
+ onReunion: () -> Unit,
onAbrir: (Long) -> Unit,
onAjustes: () -> Unit,
) {
@@ -76,6 +77,11 @@ fun SesionesScreen(
Accion("grabar", "captura ahora", NeonVerde, Modifier.weight(1f), onGrabar)
Accion("vigilia", "monitor nocturno", NeonCian, Modifier.weight(1f), onVigilia)
}
+ Spacer(Modifier.height(12.dp))
+ Accion(
+ "reunión", "transcribe lo que se habla · offline", NeonRosa,
+ Modifier.fillMaxWidth(), onReunion,
+ )
Spacer(Modifier.height(24.dp))
Text(
@@ -121,10 +127,13 @@ private fun FilaSesion(s: Sesion, onClick: () -> Unit) {
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- val esVigilia = s.modo == Modo.VIGILIA
+ val puntoColor = when (s.modo) {
+ Modo.VIGILIA -> NeonCian
+ Modo.REUNION -> NeonRosa
+ else -> NeonVerde
+ }
Box(
- Modifier.size(10.dp).clip(CircleShape)
- .background(if (esVigilia) NeonCian else NeonVerde),
+ Modifier.size(10.dp).clip(CircleShape).background(puntoColor),
)
Spacer(Modifier.size(14.dp))
Column(Modifier.weight(1f)) {
diff --git a/vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt b/vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt
index d2f3289..b165b4a 100644
--- a/vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt
+++ b/vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt
@@ -33,6 +33,7 @@ fun VelaApp(vm: VelaViewModel) {
error = vm.errorMic,
onGrabar = { vm.grabar(Modo.NORMAL) },
onVigilia = { vm.grabar(Modo.VIGILIA) },
+ onReunion = { vm.grabar(Modo.REUNION) },
onAbrir = { vm.abrir(it) },
onAjustes = { vm.irAjustes() },
)
@@ -58,6 +59,14 @@ fun VelaApp(vm: VelaViewModel) {
}
}
+ Pantalla.Modelo -> ModeloScreen(
+ descargando = vm.modeloDescargando,
+ progreso = vm.modeloProgreso,
+ error = vm.modeloError,
+ onDescargar = { vm.descargarModelo() },
+ onVolver = { vm.volver() },
+ )
+
Pantalla.Ajustes -> AjustesScreen(
cfg = vm.cfg,
onSensibilidad = { vm.setSensibilidad(it) },