gioser

sergio/humanoid

HTML 38Markdown 1TOML 10 Definiciones

⎇ 1aeaadc9025156a07cf485e41f8a26788aaff824 vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt 338 líneas · 12.3 KB Historia crudo

vela: formato de audio opcional — vigilia en .aac, día en .m4a 1aeaadc90 · sergio ·

1package com.rizoma.vela.audio
2
3import android.Manifest
4import android.content.Context
5import android.content.pm.PackageManager
6import android.media.AudioFormat
7import android.media.AudioRecord
8import android.media.MediaRecorder
9import android.os.Handler
10import android.os.Looper
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableStateListOf
13import androidx.compose.runtime.mutableStateOf
14import androidx.compose.runtime.setValue
15import androidx.compose.runtime.snapshots.SnapshotStateList
16import androidx.core.content.ContextCompat
17import com.rizoma.vela.data.AjustesActuales
18import com.rizoma.vela.data.Formato
19import com.rizoma.vela.data.Modo
20import com.rizoma.vela.data.Repositorio
21import com.rizoma.vela.data.Segmento
22import kotlin.math.log10
23import kotlin.math.sqrt
24
25enum class EstadoGrabadora { INACTIVA, ESCUCHANDO, GRABANDO }
26
27/**
28 * Estado del motor observable por Compose (mismo proceso, lectura directa).
29 * Vive fuera del ViewModel para sobrevivir a que la app pierda el foco: lo
30 * posee el servicio de primer plano.
31 */
32class GrabadoraHolder {
33    var estado by mutableStateOf(EstadoGrabadora.INACTIVA); internal set
34    var modo by mutableStateOf(Modo.NORMAL); internal set
35    var activa by mutableStateOf(false); internal set
36
37    var sesionId by mutableStateOf(0L); internal set
38    var inicioEpoch by mutableStateOf(0L); internal set
39
40    /** Nivel instantáneo y pico reciente, en dBFS (−70..0) para el medidor. */
41    var nivelDb by mutableStateOf(-70f); internal set
42    var picoDb by mutableStateOf(-70f); internal set
43    /** Umbral de la puerta ahora mismo (línea de disparo del medidor). */
44    var umbralDb by mutableStateOf(-40f); internal set
45
46    var duracionRealMs by mutableStateOf(0L); internal set
47    var duracionSonoraMs by mutableStateOf(0L); internal set
48
49    /** Texto en curso del fragmento activo (modo reunión). */
50    var parcial by mutableStateOf(""); internal set
51
52    /** Fragmentos de la sesión en curso (crece en vivo). */
53    val segmentos: SnapshotStateList<Segmento> = mutableStateListOf()
54
55    /** Historial de niveles para la onda; se lee tras suscribirse a [ondasVer]. */
56    val ondas = FloatArray(ANCHO_ONDA) { -70f }
57    var ondasVer by mutableStateOf(0); internal set
58
59    internal fun reset() {
60        segmentos.clear()
61        ondas.fill(-70f)
62        ondasVer = 0
63        nivelDb = -70f; picoDb = -70f
64        duracionRealMs = 0L; duracionSonoraMs = 0L
65        parcial = ""
66    }
67
68    companion object { const val ANCHO_ONDA = 160 }
69}
70
71/**
72 * Motor de grabación inteligente. Captura PCM 16-bit mono con [AudioRecord],
73 * pasa cada cuadro (20 ms) por la [Puerta] de voz y **solo codifica lo que
74 * suena** (compacta silencios). Con pre-roll no corta el arranque de las
75 * palabras y con cola no corta el final. En silencio solo calcula RMS: barato,
76 * ideal para velar de noche ahorrando batería.
77 *
78 * En modo reunión bifurca el mismo PCM hacia un [Transcriptor] (Vosk, offline)
79 * que corre en su propio hilo, sin retrasar la captura.
80 *
81 * Toda la captura corre en un hilo propio; el estado se publica al hilo
82 * principal para que Compose lo lea.
83 */
84class Grabadora(
85    private val context: Context,
86    private val repo: Repositorio,
87    ajustes: AjustesActuales,
88    val holder: GrabadoraHolder,
89    private val modo: Modo,
90    /** Se llama (en el hilo principal) cuando se cierra un fragmento. */
91    private val onSegmento: (Int) -> Unit = {},
92) {
93    private val principal = Handler(Looper.getMainLooper())
94    // Reunión fuerza 16 kHz: es lo que espera el modelo de voz y sobra para hablar.
95    private val sampleRate = if (modo == Modo.REUNION) 16_000 else ajustes.sampleRate
96    private val frame = sampleRate / 50            // 20 ms por cuadro
97    private val preRollCuadros = (ajustes.preRollMs / 20).coerceAtLeast(0)
98    private val colaCuadros = (ajustes.colaMs / 20).coerceAtLeast(0)
99    // La noche va en flujo crudo (a prueba de cortes); el día en .m4a.
100    private val formato = if (modo == Modo.VIGILIA) ajustes.formatoVigilia else ajustes.formatoDia
101
102    private var record: AudioRecord? = null
103    private var codificador: Codificador? = null
104    private var transcriptor: Transcriptor? = null
105    private var hilo: Thread? = null
106    @Volatile private var corriendo = false
107
108    private val puerta = Puerta(ajustes.sensibilidad)
109
110    // Lista canónica de fragmentos; la mutan el hilo de captura y el de Vosk.
111    private val lock = Any()
112    private val segmentos = ArrayList<Segmento>()
113    @Volatile private var lastReal = 0L
114    @Volatile private var lastSonora = 0L
115
116    private var id = 0L
117    private var inicioEpoch = 0L
118
119    /** ¿Se pudo abrir el micrófono? Devuelve false si faltó permiso/hardware. */
120    fun iniciar(): Boolean {
121        if (corriendo) return true
122        if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
123            != PackageManager.PERMISSION_GRANTED
124        ) return false
125
126        val minBuf = AudioRecord.getMinBufferSize(
127            sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
128        )
129        if (minBuf <= 0) return false
130        val bufSize = maxOf(minBuf, frame * 2 * 8)
131
132        val rec = try {
133            @Suppress("MissingPermission")
134            AudioRecord(
135                MediaRecorder.AudioSource.MIC, sampleRate,
136                AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufSize,
137            )
138        } catch (_: Exception) {
139            return false
140        }
141        if (rec.state != AudioRecord.STATE_INITIALIZED) {
142            rec.release(); return false
143        }
144
145        id = System.currentTimeMillis()
146        inicioEpoch = id
147        val dir = repo.carpeta(id)
148        val bitRate = if (sampleRate <= 16_000) 32_000 else 64_000
149        codificador = crearCodificador(formato, sampleRate, bitRate, java.io.File(dir, "audio.${formato.ext}"))
150        record = rec
151        synchronized(lock) { segmentos.clear() }
152
153        if (modo == Modo.REUNION) {
154            transcriptor = Transcriptor.crear(
155                context,
156                onParcial = { _, txt -> principal.post { holder.parcial = txt } },
157                onFinal = { idx, txt -> ponerTexto(idx, txt) },
158            )
159        }
160
161        rec.startRecording()
162        corriendo = true
163
164        holder.reset()
165        holder.modo = modo
166        holder.sesionId = id
167        holder.inicioEpoch = inicioEpoch
168        holder.activa = true
169        holder.estado = EstadoGrabadora.ESCUCHANDO
170
171        hilo = Thread({ bucle() }, "vela-captura").apply { start() }
172        return true
173    }
174
175    fun detener() {
176        if (!corriendo) return
177        corriendo = false
178        hilo?.join(2_000)
179        hilo = null
180        transcriptor?.detener()
181        transcriptor = null
182    }
183
184    // ---- Bucle de captura (hilo propio) -------------------------------
185
186    private fun bucle() {
187        val rec = record ?: return
188        val cod = codificador ?: return
189        val tr = transcriptor
190        val buf = ShortArray(frame)
191        val ring = ArrayDeque<ShortArray>()   // pre-roll: últimos cuadros de silencio
192
193        var enSonido = false
194        var colaRestante = 0
195        var segIdx = 0
196        var segInicioReal = 0L
197        var segArchivoUs = 0L
198        var segPico = -120f
199        var segSuma = 0f
200        var segFrames = 0
201        var indice = 0
202        var cuadro = 0
203
204        while (corriendo) {
205            val n = rec.read(buf, 0, frame)
206            if (n <= 0) continue
207            val db = dbDe(buf, n)
208            val activa = puerta.procesar(db)
209
210            if (activa) {
211                colaRestante = colaCuadros
212                if (!enSonido) {
213                    // Abrir fragmento: fija tiempos y vuelca el pre-roll (el
214                    // arranque de la palabra que estaba en el ring).
215                    enSonido = true
216                    segIdx = indice++
217                    holder.estado = EstadoGrabadora.GRABANDO
218                    principal.post { holder.parcial = "" }
219                    segInicioReal = System.currentTimeMillis() - inicioEpoch
220                    segArchivoUs = cod.currentFileUs()
221                    segPico = -120f; segSuma = 0f; segFrames = 0
222                    tr?.abrir(segIdx)
223                    while (ring.isNotEmpty()) {
224                        val f = ring.removeFirst()
225                        cod.feed(f, f.size); tr?.alimentar(f, f.size, segIdx)
226                    }
227                }
228            } else if (enSonido) {
229                if (colaRestante > 0) colaRestante-- else {
230                    cerrarSegmento(cod, segIdx, segInicioReal, segArchivoUs, segPico, segSuma, segFrames)
231                    tr?.cerrar(segIdx)
232                    enSonido = false
233                    holder.estado = EstadoGrabadora.ESCUCHANDO
234                }
235            }
236
237            if (enSonido) {
238                cod.feed(buf, n); tr?.alimentar(buf, n, segIdx)
239                segFrames++; segSuma += db; if (db > segPico) segPico = db
240            } else {
241                ring.addLast(buf.copyOf(n))
242                while (ring.size > preRollCuadros) ring.removeFirst()
243            }
244
245            // Publicar medidor ~cada 2 cuadros (25 fps) para no saturar el hilo UI.
246            if (cuadro % 2 == 0) publicarMedidor(db, cod.currentFileUs())
247            cuadro++
248        }
249
250        // Cierre: fragmento abierto + EOS del codificador + log final.
251        if (enSonido) {
252            cerrarSegmento(cod, segIdx, segInicioReal, segArchivoUs, segPico, segSuma, segFrames)
253            tr?.cerrar(segIdx)
254        }
255        cod.finish()
256        runCatching { rec.stop() }
257        runCatching { rec.release() }
258        record = null
259        codificador = null
260
261        val real = System.currentTimeMillis() - inicioEpoch
262        val sonora = cod.currentFileUs() / 1000
263        lastReal = real; lastSonora = sonora
264        synchronized(lock) { guardarLog() }
265        principal.post {
266            holder.duracionRealMs = real
267            holder.duracionSonoraMs = sonora
268            holder.parcial = ""
269            holder.activa = false
270            holder.estado = EstadoGrabadora.INACTIVA
271        }
272    }
273
274    private fun cerrarSegmento(
275        cod: Codificador, idx: Int, inicioReal: Long, archivoUs: Long,
276        pico: Float, suma: Float, frames: Int,
277    ) {
278        val durMs = (cod.currentFileUs() - archivoUs) / 1000
279        val medio = if (frames > 0) suma / frames else -70f
280        val seg = Segmento(idx, inicioReal, durMs, archivoUs / 1000, pico, medio)
281        lastReal = System.currentTimeMillis() - inicioEpoch
282        lastSonora = cod.currentFileUs() / 1000
283        synchronized(lock) {
284            segmentos.add(seg)
285            guardarLog()
286        }
287        principal.post {
288            holder.segmentos.add(seg)
289            holder.duracionSonoraMs = lastSonora
290            onSegmento(segmentos.size)
291        }
292    }
293
294    /** El transcriptor (otro hilo) entrega el texto de un fragmento ya cerrado. */
295    private fun ponerTexto(idx: Int, texto: String) {
296        val nuevo: Segmento
297        synchronized(lock) {
298            if (idx >= segmentos.size) return
299            nuevo = segmentos[idx].copy(texto = texto.ifBlank { null })
300            segmentos[idx] = nuevo
301            guardarLog()
302        }
303        principal.post {
304            if (idx < holder.segmentos.size) holder.segmentos[idx] = nuevo
305        }
306    }
307
308    /** Reescribe el log completo. Debe llamarse dentro de synchronized(lock). */
309    private fun guardarLog() {
310        repo.escribirLog(id, inicioEpoch, sampleRate, modo, formato, lastReal, lastSonora, ArrayList(segmentos))
311    }
312
313    private fun publicarMedidor(db: Float, fileUs: Long) {
314        val umbral = puerta.umbralDb
315        val real = System.currentTimeMillis() - inicioEpoch
316        principal.post {
317            holder.nivelDb = db
318            holder.umbralDb = umbral
319            holder.picoDb = maxOf(db, holder.picoDb - 1.5f) // decae suave
320            holder.duracionRealMs = real
321            holder.duracionSonoraMs = fileUs / 1000
322            val o = holder.ondas
323            System.arraycopy(o, 1, o, 0, o.size - 1)
324            o[o.size - 1] = db
325            holder.ondasVer++
326        }
327    }
328
329    private fun dbDe(buf: ShortArray, n: Int): Float {
330        var sum = 0.0
331        for (i in 0 until n) {
332            val s = buf[i].toDouble(); sum += s * s
333        }
334        val rms = sqrt(sum / n)
335        val db = 20.0 * log10(rms / 32768.0 + 1e-9)
336        return db.toFloat().coerceIn(-70f, 0f)
337    }
338}