tawasuyu

sergio/humanoid

HTML 38Markdown 1TOML 10 Definiciones

⎇ 7bf8cd2caf0c5cec59294fef958b3220e7f4612b vela/src/main/java/com/rizoma/vela/audio/Transcriptor.kt 171 líneas · 6.5 KB Historia crudo

vela: arreglar el nombre del método de Vosk (acceptWaveForm) 7bf8cd2ca · Sergio ·

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}