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().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, @@ -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, 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() }, ) }