⎇ 7bf8cd2caf0c5cec59294fef958b3220e7f4612b
vela/src/main/java/com/rizoma/vela/audio/Transcriptor.kt
History
raw
vela: arreglar el nombre del método de Vosk (acceptWaveForm)
1package com.rizoma.vela.audio
2
3import android.content.Context
4import org.json.JSONObject
5import org.vosk.LogLevel
6import org.vosk.LibVosk
7import org.vosk.Model
8import org.vosk.Recognizer
9import java.io.File
10import java.net.HttpURLConnection
11import java.net.URL
12import java.util.concurrent.LinkedBlockingQueue
13import java.util.zip.ZipInputStream
14
15/**
16 * Transcripción offline con Vosk, **solo para el modo reunión**. Se alimenta
17 * del mismo PCM que ya captura la [Grabadora], pero el reconocimiento pesado
18 * corre en un **hilo propio** (cola productor/consumidor) para no retrasar el
19 * hilo de audio en tiempo real ni ensuciar la grabación.
20 *
21 * El reconocedor se reinicia por fragmento: cada fragmento del log recibe su
22 * propio texto, alineado con la línea de tiempo.
23 *
24 * El modelo (~45 MB) NO va en el APK: se descarga y descomprime la primera vez
25 * ([descargar]); a partir de ahí todo es offline.
26 */
27class Transcriptor private constructor(
28 private val modelo: Model,
29 /** Texto en curso del fragmento (para mostrar en vivo). */
30 private val onParcial: (Int, String) -> Unit,
31 /** Texto final de un fragmento ya cerrado. */
32 private val onFinal: (Int, String) -> Unit,
33) {
34 private val recognizer = Recognizer(modelo, 16_000.0f)
35 private val cola = LinkedBlockingQueue<Tarea>()
36 private val worker = Thread({ bucle() }, "vela-vosk").apply { start() }
37
38 private sealed interface Tarea {
39 data class Abrir(val idx: Int) : Tarea
40 class Audio(val pcm: ShortArray, val n: Int, val idx: Int) : Tarea
41 data class Cerrar(val idx: Int) : Tarea
42 data object Fin : Tarea
43 }
44
45 fun abrir(idx: Int) { cola.put(Tarea.Abrir(idx)) }
46 fun alimentar(pcm: ShortArray, n: Int, idx: Int) { cola.put(Tarea.Audio(pcm.copyOf(n), n, idx)) }
47 fun cerrar(idx: Int) { cola.put(Tarea.Cerrar(idx)) }
48
49 /** Vacía la cola pendiente, cierra Vosk y suelta el modelo. */
50 fun detener() {
51 cola.put(Tarea.Fin)
52 worker.join(4_000)
53 }
54
55 private fun bucle() {
56 var acc = StringBuilder()
57 var cuadros = 0
58 while (true) {
59 when (val t = cola.take()) {
60 is Tarea.Abrir -> {
61 recognizer.reset(); acc = StringBuilder(); cuadros = 0
62 }
63
64 is Tarea.Audio -> {
65 val fin = recognizer.acceptWaveForm(t.pcm, t.n)
66 if (fin) acc.append(textoDe(recognizer.getResult(), "text")).append(' ')
67 // Parcial en vivo cada ~5 cuadros (100 ms) para no saturar.
68 if (cuadros++ % 5 == 0) {
69 val vivo = (acc.toString() + textoDe(recognizer.getPartialResult(), "partial")).trim()
70 onParcial(t.idx, vivo)
71 }
72 }
73
74 is Tarea.Cerrar -> {
75 acc.append(textoDe(recognizer.getFinalResult(), "text"))
76 onFinal(t.idx, acc.toString().trim())
77 acc = StringBuilder()
78 }
79
80 Tarea.Fin -> break
81 }
82 }
83 runCatching { recognizer.close() }
84 runCatching { modelo.close() }
85 }
86
87 private fun textoDe(json: String, clave: String): String =
88 runCatching { JSONObject(json).optString(clave, "") }.getOrDefault("")
89
90 companion object {
91 private const val URL_MODELO =
92 "https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip"
93 private const val CARPETA = "vosk-model-small-es-0.42"
94
95 private fun base(context: Context) = File(context.filesDir, "vosk")
96 private fun ruta(context: Context) = File(base(context), CARPETA)
97
98 /** ¿Está el modelo ya descomprimido y usable? */
99 fun modeloPresente(context: Context): Boolean =
100 File(ruta(context), "am").exists() || File(ruta(context), "conf").exists()
101
102 /**
103 * Descarga y descomprime el modelo. [onProgreso] recibe 0..1 (o −1 si
104 * no se conoce el tamaño). Bloqueante: llamar en un hilo de fondo.
105 */
106 fun descargar(context: Context, onProgreso: (Float) -> Unit) {
107 val base = base(context).apply { mkdirs() }
108 val zip = File(base, "modelo.zip.tmp")
109 val con = (URL(URL_MODELO).openConnection() as HttpURLConnection).apply {
110 connectTimeout = 20_000; readTimeout = 30_000
111 }
112 try {
113 con.connect()
114 val total = con.contentLength.toLong()
115 var leido = 0L
116 con.inputStream.use { ent ->
117 zip.outputStream().use { sal ->
118 val buf = ByteArray(64 * 1024)
119 while (true) {
120 val n = ent.read(buf); if (n < 0) break
121 sal.write(buf, 0, n); leido += n
122 onProgreso(if (total > 0) (leido.toFloat() / total) else -1f)
123 }
124 }
125 }
126 descomprimir(zip, base)
127 } finally {
128 con.disconnect()
129 zip.delete()
130 }
131 }
132
133 private fun descomprimir(zip: File, destino: File) {
134 ZipInputStream(zip.inputStream().buffered()).use { z ->
135 var e = z.nextEntry
136 while (e != null) {
137 val f = File(destino, e.name)
138 // Defensa básica contra zip-slip.
139 if (!f.canonicalPath.startsWith(destino.canonicalPath)) {
140 e = z.nextEntry; continue
141 }
142 if (e.isDirectory) f.mkdirs() else {
143 f.parentFile?.mkdirs()
144 f.outputStream().use { z.copyTo(it) }
145 }
146 z.closeEntry()
147 e = z.nextEntry
148 }
149 }
150 }
151
152 /**
153 * Crea un transcriptor cargando el modelo del disco. Devuelve null si el
154 * modelo no está o falla la carga (la grabación sigue sin transcripción).
155 */
156 fun crear(
157 context: Context,
158 onParcial: (Int, String) -> Unit,
159 onFinal: (Int, String) -> Unit,
160 ): Transcriptor? {
161 if (!modeloPresente(context)) return null
162 return try {
163 LibVosk.setLogLevel(LogLevel.WARNINGS)
164 val modelo = Model(ruta(context).absolutePath)
165 Transcriptor(modelo, onParcial, onFinal)
166 } catch (_: Throwable) {
167 null
168 }
169 }
170 }
171}