gioser

sergio/humanoid

HTML 38Markdown 1TOML 10 Sutichaykuna

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

El contenedor ahora es una opción (Ajustes) con defaults por modo:
- vigilia nocturna → AAC crudo (ADTS .aac): a prueba de cortes, si el
  proceso muere a media noche el archivo se reproduce hasta donde llegó
  (no depende de un índice final).
- grabar / reunión → .m4a (MP4): mejor búsqueda; son sesiones atendidas.

Refactor del codificador: interfaz Codificador + base común (posee el
MediaCodec AAC-LC) + dos contenedores (CodificadorM4a con MediaMuxer,
CodificadorAac que escribe frames ADTS con su cabecera de 7 bytes a un
flujo plano). Factoría crearCodificador(formato, ...).

Sesion/Repositorio guardan "fmt" (sesiones viejas = .m4a); el nombre de
archivo usa la extensión del formato. Ajustes: formatoVigilia (def AAC) y
formatoDia (def M4A) con selector en la pantalla de ajustes.

NO compilado aquí (sin SDK en este equipo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

1aeaadc9025156a07cf485e41f8a26788aaff824 · ruwaq sergio · (3 killa ñawpaq) · tayta 81bd1537a · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

10 qillqakuna · +303 −117

diff --git a/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt b/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt
index 027baf5..1c035d4 100644
--- a/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt
+++ b/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt
@@ -15,6 +15,7 @@ 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.Formato
 import com.rizoma.vela.data.Modo
 import com.rizoma.vela.data.Repositorio
 import com.rizoma.vela.data.Sesion
@@ -160,6 +161,8 @@ class VelaViewModel(app: Application) : AndroidViewModel(app) {
     fun setPreRoll(v: Int) = viewModelScope.launch { ajustes.setPreRoll(v) }
     fun setCola(v: Int) = viewModelScope.launch { ajustes.setCola(v) }
     fun setMantenerPantalla(v: Boolean) = viewModelScope.launch { ajustes.setMantenerPantalla(v) }
+    fun setFormatoVigilia(v: Formato) = viewModelScope.launch { ajustes.setFormatoVigilia(v) }
+    fun setFormatoDia(v: Formato) = viewModelScope.launch { ajustes.setFormatoDia(v) }
 
     override fun onCleared() {
         runCatching { getApplication<Application>().unbindService(conexion) }
diff --git a/vela/src/main/java/com/rizoma/vela/audio/Codificador.kt b/vela/src/main/java/com/rizoma/vela/audio/Codificador.kt
new file mode 100644
index 0000000..3ca98e9
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/audio/Codificador.kt
@@ -0,0 +1,131 @@
+package com.rizoma.vela.audio
+
+import android.media.MediaCodec
+import android.media.MediaCodecInfo
+import android.media.MediaFormat
+import com.rizoma.vela.data.Formato
+import java.io.File
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+/**
+ * Codificador de audio: se le entrega PCM 16-bit mono **solo cuando hay
+ * sonido** y produce un archivo. El "reloj" del archivo ([currentFileUs])
+ * avanza únicamente con las muestras entregadas, así el audio queda contiguo
+ * (silencios compactados) aunque entre fragmento y fragmento pasen horas.
+ *
+ * Uso desde un único hilo (el de captura).
+ */
+interface Codificador {
+    fun feed(pcm: ShortArray, n: Int)
+    fun currentFileUs(): Long
+    fun finish()
+}
+
+/** Crea el codificador del [formato] pedido. */
+fun crearCodificador(
+    formato: Formato, sampleRate: Int, bitRate: Int, salida: File,
+): Codificador = when (formato) {
+    Formato.AAC -> CodificadorAac(sampleRate, bitRate, salida)
+    Formato.M4A -> CodificadorM4a(sampleRate, bitRate, salida)
+}
+
+/**
+ * Base común: posee el `MediaCodec` AAC-LC (framework, sin deps ni LAME),
+ * gestiona entrada/salida y delega la escritura al contenedor concreto.
+ */
+abstract class CodificadorBase(
+    protected val sampleRate: Int,
+    bitRate: Int,
+) : Codificador {
+
+    private val codec: MediaCodec =
+        MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
+    private val info = MediaCodec.BufferInfo()
+    private var muestras = 0L
+    private var terminado = false
+
+    init {
+        val fmt = MediaFormat.createAudioFormat(
+            MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, 1,
+        ).apply {
+            setInteger(
+                MediaFormat.KEY_AAC_PROFILE,
+                MediaCodecInfo.CodecProfileLevel.AACObjectLC,
+            )
+            setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
+            setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192)
+        }
+        codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
+        codec.start()
+    }
+
+    final override fun currentFileUs(): Long = muestras * 1_000_000L / sampleRate
+
+    final override fun feed(pcm: ShortArray, n: Int) {
+        if (terminado || n <= 0) return
+        val idx = codec.dequeueInputBuffer(10_000)
+        if (idx >= 0) {
+            val buf = codec.getInputBuffer(idx) ?: return
+            buf.clear()
+            buf.order(ByteOrder.LITTLE_ENDIAN)
+            buf.asShortBuffer().put(pcm, 0, n)
+            codec.queueInputBuffer(idx, 0, n * 2, currentFileUs(), 0)
+            muestras += n
+        }
+        drenar(false)
+    }
+
+    private fun drenar(fin: Boolean) {
+        while (true) {
+            val idx = codec.dequeueOutputBuffer(info, if (fin) 10_000 else 0)
+            when {
+                idx == MediaCodec.INFO_TRY_AGAIN_LATER -> if (!fin) return
+
+                idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> onFormato(codec.outputFormat)
+
+                idx >= 0 -> {
+                    val buf = codec.getOutputBuffer(idx)
+                    // El buffer de configuración del códec no es audio: descártalo.
+                    if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) info.size = 0
+                    if (info.size > 0 && buf != null) {
+                        buf.position(info.offset)
+                        buf.limit(info.offset + info.size)
+                        onMuestra(buf, info)
+                    }
+                    codec.releaseOutputBuffer(idx, false)
+                    if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return
+                }
+            }
+        }
+    }
+
+    final override fun finish() {
+        if (terminado) return
+        terminado = true
+        try {
+            val idx = codec.dequeueInputBuffer(10_000)
+            if (idx >= 0) {
+                codec.queueInputBuffer(
+                    idx, 0, 0, currentFileUs(), MediaCodec.BUFFER_FLAG_END_OF_STREAM,
+                )
+            }
+            drenar(true)
+        } catch (_: Exception) {
+            // pase lo que pase, soltamos los recursos abajo
+        } finally {
+            runCatching { codec.stop() }
+            runCatching { codec.release() }
+            onCerrar()
+        }
+    }
+
+    /** El códec anunció su formato de salida (útil para el muxer). */
+    protected abstract fun onFormato(format: MediaFormat)
+
+    /** Un trozo de AAC codificado, listo para escribir (buffer ya posicionado). */
+    protected abstract fun onMuestra(buffer: ByteBuffer, info: MediaCodec.BufferInfo)
+
+    /** Cierre del contenedor. */
+    protected abstract fun onCerrar()
+}
diff --git a/vela/src/main/java/com/rizoma/vela/audio/CodificadorAac.kt b/vela/src/main/java/com/rizoma/vela/audio/CodificadorAac.kt
index 8512bae..8684ae1 100644
--- a/vela/src/main/java/com/rizoma/vela/audio/CodificadorAac.kt
+++ b/vela/src/main/java/com/rizoma/vela/audio/CodificadorAac.kt
@@ -1,134 +1,65 @@
 package com.rizoma.vela.audio
 
 import android.media.MediaCodec
-import android.media.MediaCodecInfo
 import android.media.MediaFormat
-import android.media.MediaMuxer
+import java.io.BufferedOutputStream
 import java.io.File
+import java.io.FileOutputStream
 import java.nio.ByteBuffer
-import java.nio.ByteOrder
 
 /**
- * Codificador AAC-LC → contenedor MP4 (`.m4a`), todo con el framework (sin deps
- * nativas ni LAME). Se le entrega PCM 16-bit mono **solo cuando hay sonido**;
- * los silencios no se codifican, así el archivo queda contiguo y compacto.
- *
- * El "reloj" del archivo ([currentFileUs]) avanza únicamente con las muestras
- * realmente entregadas, no con el reloj de pared. Por eso el audio no tiene
- * huecos aunque entre fragmento y fragmento pasen horas.
- *
- * Uso desde un único hilo (el hilo de captura): [feed] por cada trozo con
- * sonido y [finish] al terminar.
+ * AAC-LC en flujo crudo **ADTS** (`.aac`): a cada trozo se le antepone su
+ * cabecera ADTS de 7 bytes y se vuelca a un archivo plano, sin muxer. Como no
+ * hay índice global, si el proceso muere a mitad el archivo **sigue siendo
+ * reproducible hasta donde llegó** — justo lo que quieres para velar toda la
+ * noche desatendido. La búsqueda es aproximada (sin índice).
  */
 class CodificadorAac(
-    private val sampleRate: Int,
-    private val bitRate: Int,
-    salida: File,
-) {
-    private val codec: MediaCodec =
-        MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
-    private val muxer: MediaMuxer =
-        MediaMuxer(salida.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
-    private val info = MediaCodec.BufferInfo()
+    sampleRate: Int, bitRate: Int, salida: File,
+) : CodificadorBase(sampleRate, bitRate) {
 
-    private var pista = -1
-    private var muxerArrancado = false
-    private var terminado = false
+    private val out = BufferedOutputStream(FileOutputStream(salida))
+    private val freqIdx = indiceFrecuencia(sampleRate)
+    private val bytes = ByteArray(8192)
 
-    /** Muestras (por canal) entregadas hasta ahora → base de los timestamps. */
-    private var muestras = 0L
+    override fun onFormato(format: MediaFormat) { /* ADTS es autodescriptivo */ }
 
-    init {
-        val fmt = MediaFormat.createAudioFormat(
-            MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, 1,
-        ).apply {
-            setInteger(
-                MediaFormat.KEY_AAC_PROFILE,
-                MediaCodecInfo.CodecProfileLevel.AACObjectLC,
-            )
-            setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
-            setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192)
-        }
-        codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
-        codec.start()
+    override fun onMuestra(buffer: ByteBuffer, info: MediaCodec.BufferInfo) {
+        val n = info.size
+        out.write(cabeceraAdts(n))
+        buffer.get(bytes, 0, n) // buffer ya posicionado en info.offset
+        out.write(bytes, 0, n)
     }
 
-    /** Posición actual del audio compactado, en microsegundos. */
-    fun currentFileUs(): Long = muestras * 1_000_000L / sampleRate
-
-    /** Entrega [n] muestras de [pcm] (mono, 16-bit) al codificador. */
-    fun feed(pcm: ShortArray, n: Int) {
-        if (terminado || n <= 0) return
-        val idx = codec.dequeueInputBuffer(10_000)
-        if (idx >= 0) {
-            val buf = codec.getInputBuffer(idx) ?: return
-            buf.clear()
-            buf.order(ByteOrder.LITTLE_ENDIAN)
-            val sh = buf.asShortBuffer()
-            sh.put(pcm, 0, n)
-            val bytes = n * 2
-            val pts = currentFileUs()
-            codec.queueInputBuffer(idx, 0, bytes, pts, 0)
-            muestras += n
-        }
-        drenar(false)
+    override fun onCerrar() {
+        runCatching { out.flush(); out.close() }
     }
 
-    private fun drenar(fin: Boolean) {
-        while (true) {
-            val idx = codec.dequeueOutputBuffer(info, if (fin) 10_000 else 0)
-            when {
-                idx == MediaCodec.INFO_TRY_AGAIN_LATER -> {
-                    if (!fin) return // sin salida lista todavía; seguimos capturando
-                    // en fin: seguir esperando el EOS
-                }
-
-                idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
-                    if (!muxerArrancado) {
-                        pista = muxer.addTrack(codec.outputFormat)
-                        muxer.start()
-                        muxerArrancado = true
-                    }
-                }
-
-                idx >= 0 -> {
-                    val buf = codec.getOutputBuffer(idx)
-                    // El buffer de configuración del códec no es audio: descártalo.
-                    if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
-                        info.size = 0
-                    }
-                    if (info.size > 0 && muxerArrancado && buf != null) {
-                        buf.position(info.offset)
-                        buf.limit(info.offset + info.size)
-                        muxer.writeSampleData(pista, buf, info)
-                    }
-                    codec.releaseOutputBuffer(idx, false)
-                    if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return
-                }
-            }
-        }
+    /** Cabecera ADTS de 7 bytes (AAC-LC, sin CRC) para un frame de [len] bytes. */
+    private fun cabeceraAdts(len: Int): ByteArray {
+        val perfil = 2                 // AAC LC (AOT); en ADTS se codifica perfil-1
+        val canales = 1                // mono
+        val total = len + 7
+        val h = ByteArray(7)
+        h[0] = 0xFF.toByte()
+        h[1] = 0xF1.toByte()           // syncword + MPEG-4, capa 0, sin CRC
+        h[2] = (((perfil - 1) shl 6) or (freqIdx shl 2) or (canales shr 2)).toByte()
+        h[3] = (((canales and 3) shl 6) or (total shr 11)).toByte()
+        h[4] = ((total shr 3) and 0xFF).toByte()
+        h[5] = (((total and 7) shl 5) or 0x1F).toByte()
+        h[6] = 0xFC.toByte()
+        return h
     }
 
-    /** Cierra el flujo: EOS, vacía la salida pendiente y suelta todo. */
-    fun finish() {
-        if (terminado) return
-        terminado = true
-        try {
-            val idx = codec.dequeueInputBuffer(10_000)
-            if (idx >= 0) {
-                codec.queueInputBuffer(
-                    idx, 0, 0, currentFileUs(),
-                    MediaCodec.BUFFER_FLAG_END_OF_STREAM,
-                )
-            }
-            drenar(true)
-        } catch (_: Exception) {
-            // pase lo que pase, hay que soltar los recursos abajo
-        } finally {
-            runCatching { codec.stop() }
-            runCatching { codec.release() }
-            if (muxerArrancado) runCatching { muxer.stop() }
-            runCatching { muxer.release() }
+    private companion object {
+        private val FRECUENCIAS = intArrayOf(
+            96000, 88200, 64000, 48000, 44100, 32000, 24000,
+            22050, 16000, 12000, 11025, 8000, 7350,
+        )
+
+        fun indiceFrecuencia(sr: Int): Int {
+            val i = FRECUENCIAS.indexOf(sr)
+            return if (i >= 0) i else 8 // por defecto 16 kHz
         }
     }
 }
diff --git a/vela/src/main/java/com/rizoma/vela/audio/CodificadorM4a.kt b/vela/src/main/java/com/rizoma/vela/audio/CodificadorM4a.kt
new file mode 100644
index 0000000..dd59802
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/audio/CodificadorM4a.kt
@@ -0,0 +1,39 @@
+package com.rizoma.vela.audio
+
+import android.media.MediaCodec
+import android.media.MediaFormat
+import android.media.MediaMuxer
+import java.io.File
+import java.nio.ByteBuffer
+
+/**
+ * AAC-LC dentro de un contenedor MP4 (`.m4a`). Mejor búsqueda por índice, pero
+ * el índice (`moov`) se escribe al hacer [finish]: si el proceso muere antes,
+ * el archivo puede quedar no reproducible. Bien para grabar/reunión (atendidas).
+ */
+class CodificadorM4a(
+    sampleRate: Int, bitRate: Int, salida: File,
+) : CodificadorBase(sampleRate, bitRate) {
+
+    private val muxer =
+        MediaMuxer(salida.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
+    private var pista = -1
+    private var arrancado = false
+
+    override fun onFormato(format: MediaFormat) {
+        if (!arrancado) {
+            pista = muxer.addTrack(format)
+            muxer.start()
+            arrancado = true
+        }
+    }
+
+    override fun onMuestra(buffer: ByteBuffer, info: MediaCodec.BufferInfo) {
+        if (arrancado) muxer.writeSampleData(pista, buffer, info)
+    }
+
+    override fun onCerrar() {
+        if (arrancado) runCatching { muxer.stop() }
+        runCatching { muxer.release() }
+    }
+}
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 024ff06..105678f 100644
--- a/vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt
+++ b/vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshots.SnapshotStateList
 import androidx.core.content.ContextCompat
 import com.rizoma.vela.data.AjustesActuales
+import com.rizoma.vela.data.Formato
 import com.rizoma.vela.data.Modo
 import com.rizoma.vela.data.Repositorio
 import com.rizoma.vela.data.Segmento
@@ -95,9 +96,11 @@ class Grabadora(
     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)
+    // La noche va en flujo crudo (a prueba de cortes); el día en .m4a.
+    private val formato = if (modo == Modo.VIGILIA) ajustes.formatoVigilia else ajustes.formatoDia
 
     private var record: AudioRecord? = null
-    private var codificador: CodificadorAac? = null
+    private var codificador: Codificador? = null
     private var transcriptor: Transcriptor? = null
     private var hilo: Thread? = null
     @Volatile private var corriendo = false
@@ -143,7 +146,7 @@ class Grabadora(
         inicioEpoch = id
         val dir = repo.carpeta(id)
         val bitRate = if (sampleRate <= 16_000) 32_000 else 64_000
-        codificador = CodificadorAac(sampleRate, bitRate, java.io.File(dir, "audio.m4a"))
+        codificador = crearCodificador(formato, sampleRate, bitRate, java.io.File(dir, "audio.${formato.ext}"))
         record = rec
         synchronized(lock) { segmentos.clear() }
 
@@ -269,7 +272,7 @@ class Grabadora(
     }
 
     private fun cerrarSegmento(
-        cod: CodificadorAac, idx: Int, inicioReal: Long, archivoUs: Long,
+        cod: Codificador, idx: Int, inicioReal: Long, archivoUs: Long,
         pico: Float, suma: Float, frames: Int,
     ) {
         val durMs = (cod.currentFileUs() - archivoUs) / 1000
@@ -304,7 +307,7 @@ class Grabadora(
 
     /** Reescribe el log completo. Debe llamarse dentro de synchronized(lock). */
     private fun guardarLog() {
-        repo.escribirLog(id, inicioEpoch, sampleRate, modo, lastReal, lastSonora, ArrayList(segmentos))
+        repo.escribirLog(id, inicioEpoch, sampleRate, modo, formato, lastReal, lastSonora, ArrayList(segmentos))
     }
 
     private fun publicarMedidor(db: Float, fileUs: Long) {
diff --git a/vela/src/main/java/com/rizoma/vela/data/Ajustes.kt b/vela/src/main/java/com/rizoma/vela/data/Ajustes.kt
index 8581c9d..8dfa213 100644
--- a/vela/src/main/java/com/rizoma/vela/data/Ajustes.kt
+++ b/vela/src/main/java/com/rizoma/vela/data/Ajustes.kt
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
 import androidx.datastore.preferences.core.edit
 import androidx.datastore.preferences.core.floatPreferencesKey
 import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
 import androidx.datastore.preferences.preferencesDataStore
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.first
@@ -21,6 +22,10 @@ data class AjustesActuales(
     val preRollMs: Int = 300,
     val colaMs: Int = 800,
     val mantenerPantalla: Boolean = false,
+    // La noche desatendida por defecto en flujo crudo (a prueba de cortes); el
+    // día (grabar/reunión) en contenedor .m4a (mejor búsqueda).
+    val formatoVigilia: Formato = Formato.AAC,
+    val formatoDia: Formato = Formato.M4A,
 )
 
 /**
@@ -36,9 +41,14 @@ class Ajustes(private val context: Context) {
             preRollMs = p[PREROLL] ?: 300,
             colaMs = p[COLA] ?: 800,
             mantenerPantalla = p[PANTALLA] ?: false,
+            formatoVigilia = formatoDe(p[FMT_VIGILIA], Formato.AAC),
+            formatoDia = formatoDe(p[FMT_DIA], Formato.M4A),
         )
     }
 
+    private fun formatoDe(v: String?, def: Formato): Formato =
+        runCatching { if (v != null) Formato.valueOf(v) else def }.getOrDefault(def)
+
     suspend fun actuales(): AjustesActuales = flujo.first()
 
     suspend fun setSensibilidad(v: Float) = context.dataStore.edit { it[SENSIBILIDAD] = v }
@@ -46,6 +56,8 @@ class Ajustes(private val context: Context) {
     suspend fun setPreRoll(v: Int) = context.dataStore.edit { it[PREROLL] = v }
     suspend fun setCola(v: Int) = context.dataStore.edit { it[COLA] = v }
     suspend fun setMantenerPantalla(v: Boolean) = context.dataStore.edit { it[PANTALLA] = v }
+    suspend fun setFormatoVigilia(v: Formato) = context.dataStore.edit { it[FMT_VIGILIA] = v.name }
+    suspend fun setFormatoDia(v: Formato) = context.dataStore.edit { it[FMT_DIA] = v.name }
 
     private companion object {
         val SENSIBILIDAD = floatPreferencesKey("sensibilidad")
@@ -53,5 +65,7 @@ class Ajustes(private val context: Context) {
         val PREROLL = intPreferencesKey("preroll_ms")
         val COLA = intPreferencesKey("cola_ms")
         val PANTALLA = booleanPreferencesKey("mantener_pantalla")
+        val FMT_VIGILIA = stringPreferencesKey("fmt_vigilia")
+        val FMT_DIA = stringPreferencesKey("fmt_dia")
     }
 }
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 f65ca5e..239cecf 100644
--- a/vela/src/main/java/com/rizoma/vela/data/Repositorio.kt
+++ b/vela/src/main/java/com/rizoma/vela/data/Repositorio.kt
@@ -22,6 +22,7 @@ class Repositorio(context: Context) {
         inicioEpoch: Long,
         sampleRate: Int,
         modo: Modo,
+        formato: Formato,
         duracionRealMs: Long,
         duracionSonoraMs: Long,
         segmentos: List<Segmento>,
@@ -31,6 +32,7 @@ class Repositorio(context: Context) {
             put("inicioEpoch", inicioEpoch)
             put("sampleRate", sampleRate)
             put("modo", modo.name)
+            put("fmt", formato.name)
             put("duracionRealMs", duracionRealMs)
             put("duracionSonoraMs", duracionSonoraMs)
             put("segmentos", JSONArray().apply {
@@ -89,6 +91,9 @@ class Repositorio(context: Context) {
                 sampleRate = o.optInt("sampleRate", 16000),
                 modo = runCatching { Modo.valueOf(o.optString("modo", "NORMAL")) }
                     .getOrDefault(Modo.NORMAL),
+                // Sesiones viejas no tienen "fmt": eran .m4a.
+                formato = runCatching { Formato.valueOf(o.optString("fmt", "M4A")) }
+                    .getOrDefault(Formato.M4A),
                 duracionRealMs = o.optLong("duracionRealMs"),
                 duracionSonoraMs = o.optLong("duracionSonoraMs"),
                 segmentos = segs,
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 6f71636..ddf5916 100644
--- a/vela/src/main/java/com/rizoma/vela/data/Sesion.kt
+++ b/vela/src/main/java/com/rizoma/vela/data/Sesion.kt
@@ -31,6 +31,17 @@ data class Segmento(
  */
 enum class Modo { NORMAL, VIGILIA, REUNION }
 
+/**
+ * Contenedor del audio.
+ * - AAC: flujo crudo ADTS (`.aac`). **A prueba de cortes**: si el proceso muere
+ *   a media grabación, se reproduce hasta donde llegó. Ideal para la vigilia
+ *   nocturna desatendida. Búsqueda aproximada (sin índice).
+ * - M4A: contenedor MP4 (`.m4a`). Mejor búsqueda, pero el índice (moov) solo se
+ *   escribe al parar → un corte a mitad puede dejarlo no reproducible. Bien para
+ *   grabar/reunión, que son atendidas.
+ */
+enum class Formato(val ext: String) { AAC("aac"), M4A("m4a") }
+
 /**
  * Una sesión = una carpeta con `audio.m4a` (audio compactado) y `log.json`
  * (la línea de tiempo del diálogo).
@@ -40,6 +51,7 @@ data class Sesion(
     val inicioEpoch: Long,
     val sampleRate: Int,
     val modo: Modo,
+    val formato: Formato,
     /** Duración real de reloj de toda la sesión, ms. */
     val duracionRealMs: Long,
     /** Duración del audio grabado (sin silencios), ms. */
@@ -47,7 +59,7 @@ data class Sesion(
     val segmentos: List<Segmento>,
     val dir: File,
 ) {
-    val audio: File get() = File(dir, "audio.m4a")
+    val audio: File get() = File(dir, "audio.${formato.ext}")
 
     /** Fracción de la noche que hubo sonido: útil para ver de un vistazo. */
     val fraccionSonora: Float
diff --git a/vela/src/main/java/com/rizoma/vela/ui/AjustesScreen.kt b/vela/src/main/java/com/rizoma/vela/ui/AjustesScreen.kt
index 2dff372..03974b2 100644
--- a/vela/src/main/java/com/rizoma/vela/ui/AjustesScreen.kt
+++ b/vela/src/main/java/com/rizoma/vela/ui/AjustesScreen.kt
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import com.rizoma.vela.data.AjustesActuales
+import com.rizoma.vela.data.Formato
 import com.rizoma.vela.ui.theme.MuyTenue
 import com.rizoma.vela.ui.theme.NeonCian
 import com.rizoma.vela.ui.theme.NeonVerde
@@ -44,6 +45,8 @@ fun AjustesScreen(
     onPreRoll: (Int) -> Unit,
     onCola: (Int) -> Unit,
     onMantenerPantalla: (Boolean) -> Unit,
+    onFormatoVigilia: (Formato) -> Unit,
+    onFormatoDia: (Formato) -> Unit,
     onVolver: () -> Unit,
 ) {
     Column(
@@ -84,6 +87,19 @@ fun AjustesScreen(
             OpcionSR("44.1 kHz · alta", 44_100, cfg.sampleRate, onSampleRate)
         }
 
+        Spacer(Modifier.height(20.dp))
+        Text("formato de audio", color = Tenue, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+        Text(
+            ".aac = flujo a prueba de cortes (si se muere la batería, se oye " +
+                "hasta donde llegó). .m4a = mejor para buscar, pero un corte a " +
+                "media grabación puede dañarlo.",
+            color = MuyTenue, fontSize = 12.sp,
+        )
+        Spacer(Modifier.height(10.dp))
+        FormatoFila("vigilia nocturna", cfg.formatoVigilia, onFormatoVigilia)
+        Spacer(Modifier.height(8.dp))
+        FormatoFila("grabar / reunión", cfg.formatoDia, onFormatoDia)
+
         Spacer(Modifier.height(20.dp))
         Row(
             Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
@@ -130,6 +146,36 @@ private fun Deslizador(
     }
 }
 
+@Composable
+private fun FormatoFila(etiqueta: String, actual: Formato, onClick: (Formato) -> Unit) {
+    Row(
+        Modifier.fillMaxWidth(),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.SpaceBetween,
+    ) {
+        Text(etiqueta, color = Tenue, fontSize = 14.sp)
+        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+            Chip(".aac", actual == Formato.AAC) { onClick(Formato.AAC) }
+            Chip(".m4a", actual == Formato.M4A) { onClick(Formato.M4A) }
+        }
+    }
+}
+
+@Composable
+private fun Chip(texto: String, sel: Boolean, onClick: () -> Unit) {
+    Text(
+        texto,
+        color = if (sel) Vacio else Tenue, fontSize = 13.sp,
+        fontFamily = FontFamily.Monospace,
+        modifier = Modifier
+            .clip(RoundedCornerShape(10.dp))
+            .background(if (sel) NeonVerde else Superficie)
+            .border(1.dp, NeonVerde.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
+            .clickable { onClick() }
+            .padding(horizontal = 14.dp, vertical = 9.dp),
+    )
+}
+
 @Composable
 private fun OpcionSR(texto: String, sr: Int, actual: Int, onClick: (Int) -> Unit) {
     val sel = sr == actual
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 b165b4a..78d8176 100644
--- a/vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt
+++ b/vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt
@@ -74,6 +74,8 @@ fun VelaApp(vm: VelaViewModel) {
                 onPreRoll = { vm.setPreRoll(it) },
                 onCola = { vm.setCola(it) },
                 onMantenerPantalla = { vm.setMantenerPantalla(it) },
+                onFormatoVigilia = { vm.setFormatoVigilia(it) },
+                onFormatoDia = { vm.setFormatoDia(it) },
                 onVolver = { vm.volver() },
             )
         }