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>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
10 archivos · +303 −117
- vela/src/main/java/com/rizoma/vela/VelaViewModel.kt +3 −0
- vela/src/main/java/com/rizoma/vela/audio/Codificador.kt +131 −0
- vela/src/main/java/com/rizoma/vela/audio/CodificadorAac.kt +43 −112
- vela/src/main/java/com/rizoma/vela/audio/CodificadorM4a.kt +39 −0
- vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt +7 −4
- vela/src/main/java/com/rizoma/vela/data/Ajustes.kt +14 −0
- vela/src/main/java/com/rizoma/vela/data/Repositorio.kt +5 −0
- vela/src/main/java/com/rizoma/vela/data/Sesion.kt +13 −1
- vela/src/main/java/com/rizoma/vela/ui/AjustesScreen.kt +46 −0
- vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt +2 −0
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() },
)
}