gioser

sergio/humanoid

HTML 38Markdown 1TOML 10 Definiciones

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>

81bd1537ab8e172ed59193ef9e316ad76f44b778 · por sergio · (hace 3 meses) · padre 48189204c · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

15 archivos · +476 −55

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