⎇ 1aeaadc9025156a07cf485e41f8a26788aaff824
vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt
Historia
crudo
vela: formato de audio opcional — vigilia en .aac, día en .m4a
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}