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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
81bd1537ab
commit
1aeaadc902
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user