vela: grabadora inteligente (VAD + compacta silencios + vigilia nocturna)
Nueva app del rizoma :vela (com.rizoma.vela). Graba con AudioRecord (PCM 16 kHz mono), pasa cada cuadro por una puerta de voz adaptativa con histéresis, pre-roll y cola, y solo codifica lo que suena → AAC/.m4a (MediaCodec+MediaMuxer, sin deps ni LAME). Los silencios no se guardan: el audio queda contiguo y compacto, y el log de diálogo registra los tiempos reales. - log de diálogo: línea de tiempo de fragmentos (hora, duración, nivel) con silencios colapsados; persistido en log.json (atómico) por sesión. - vigilia nocturna: foreground service tipo micrófono + wakelock parcial; en silencio solo calcula RMS (barato) → ahorra batería con pantalla apagada. - UI Compose rizoma: medidor de onda en vivo, reproducción del audio compactado con salto por fragmento, y ajustes (sensibilidad, pre-roll, cola, calidad, mantener pantalla). NO compilado aquí (sin SDK en este equipo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
28 qillqakuna · +2236 −0
- settings.gradle.kts +1 −0
- vela/build.gradle.kts +50 −0
- vela/src/main/AndroidManifest.xml +39 −0
- vela/src/main/java/com/rizoma/vela/MainActivity.kt +57 −0
- vela/src/main/java/com/rizoma/vela/VelaViewModel.kt +137 −0
- vela/src/main/java/com/rizoma/vela/audio/CodificadorAac.kt +134 −0
- vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt +281 −0
- vela/src/main/java/com/rizoma/vela/audio/Puerta.kt +43 −0
- vela/src/main/java/com/rizoma/vela/audio/Reproductor.kt +67 −0
- vela/src/main/java/com/rizoma/vela/data/Ajustes.kt +57 −0
- vela/src/main/java/com/rizoma/vela/data/Repositorio.kt +103 −0
- vela/src/main/java/com/rizoma/vela/data/Sesion.kt +48 −0
- vela/src/main/java/com/rizoma/vela/service/VigiliaService.kt +191 −0
- vela/src/main/java/com/rizoma/vela/ui/AjustesScreen.kt +146 −0
- vela/src/main/java/com/rizoma/vela/ui/Formato.kt +45 −0
- vela/src/main/java/com/rizoma/vela/ui/GrabandoScreen.kt +147 −0
- vela/src/main/java/com/rizoma/vela/ui/Medidor.kt +66 −0
- vela/src/main/java/com/rizoma/vela/ui/RegistroDialogo.kt +130 −0
- vela/src/main/java/com/rizoma/vela/ui/SesionScreen.kt +171 −0
- vela/src/main/java/com/rizoma/vela/ui/SesionesScreen.kt +155 −0
- vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt +72 −0
- vela/src/main/java/com/rizoma/vela/ui/theme/Theme.kt +36 −0
- vela/src/main/res/drawable/ic_launcher_fg.xml +26 −0
- vela/src/main/res/drawable/ic_vela.xml +12 −0
- vela/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 −0
- vela/src/main/res/values/colors.xml +4 −0
- vela/src/main/res/values/strings.xml +4 −0
- vela/src/main/res/values/themes.xml +9 −0
diff --git a/settings.gradle.kts b/settings.gradle.kts
index cdd3e4b..b90efb7 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -28,3 +28,4 @@ include(":nimbo")
include(":notas")
include(":cubo")
include(":nonet")
+include(":vela")
diff --git a/vela/build.gradle.kts b/vela/build.gradle.kts
new file mode 100644
index 0000000..2725eb3
--- /dev/null
+++ b/vela/build.gradle.kts
@@ -0,0 +1,50 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.rizoma.vela"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.rizoma.vela"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.ui.util)
+ implementation(libs.androidx.compose.foundation)
+ implementation(libs.androidx.compose.animation)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.kotlinx.coroutines.android)
+}
diff --git a/vela/src/main/AndroidManifest.xml b/vela/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..611b1a1
--- /dev/null
+++ b/vela/src/main/AndroidManifest.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <!-- Escucha el micrófono para grabar y detectar voz/sonido. -->
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
+
+ <!-- Servicio de primer plano: sigue grabando/velando con la pantalla
+ apagada. Tipo micrófono (obligatorio en Android 14+ para usar el mic
+ desde un servicio). -->
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
+ <!-- Mantiene la CPU despierta durante la vigilia nocturna (doze). -->
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
+ <!-- Necesario en Android 13+ para la notificación persistente. -->
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
+ <application
+ android:label="@string/app_name"
+ android:icon="@mipmap/ic_launcher"
+ android:theme="@style/Theme.Vela"
+ android:supportsRtl="true">
+
+ <activity
+ android:name=".MainActivity"
+ android:exported="true"
+ android:launchMode="singleTask"
+ android:configChanges="orientation|screenSize|keyboard|keyboardHidden|uiMode">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+
+ <service
+ android:name=".service.VigiliaService"
+ android:exported="false"
+ android:foregroundServiceType="microphone" />
+ </application>
+</manifest>
diff --git a/vela/src/main/java/com/rizoma/vela/MainActivity.kt b/vela/src/main/java/com/rizoma/vela/MainActivity.kt
new file mode 100644
index 0000000..9cbf42a
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/MainActivity.kt
@@ -0,0 +1,57 @@
+package com.rizoma.vela
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import com.rizoma.vela.ui.VelaApp
+import com.rizoma.vela.ui.theme.VelaTheme
+
+class MainActivity : ComponentActivity() {
+
+ private val vm: VelaViewModel by viewModels()
+
+ private val permisos = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { concedidos ->
+ vm.permiso = concedidos[Manifest.permission.RECORD_AUDIO] == true || tieneMic()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ vm.permiso = tieneMic()
+ pedirPermisos()
+
+ setContent {
+ VelaTheme { VelaApp(vm) }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Por si concedió el permiso desde ajustes del sistema.
+ vm.permiso = tieneMic()
+ }
+
+ private fun tieneMic() = ContextCompat.checkSelfPermission(
+ this, Manifest.permission.RECORD_AUDIO,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ private fun pedirPermisos() {
+ val faltan = mutableListOf<String>()
+ if (!tieneMic()) faltan.add(Manifest.permission.RECORD_AUDIO)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
+ != PackageManager.PERMISSION_GRANTED
+ ) faltan.add(Manifest.permission.POST_NOTIFICATIONS)
+ if (faltan.isNotEmpty()) permisos.launch(faltan.toTypedArray())
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt b/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt
new file mode 100644
index 0000000..f882075
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/VelaViewModel.kt
@@ -0,0 +1,137 @@
+package com.rizoma.vela
+
+import android.app.Application
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.rizoma.vela.audio.GrabadoraHolder
+import com.rizoma.vela.data.Ajustes
+import com.rizoma.vela.data.AjustesActuales
+import com.rizoma.vela.data.Modo
+import com.rizoma.vela.data.Repositorio
+import com.rizoma.vela.data.Sesion
+import com.rizoma.vela.service.VigiliaService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+sealed interface Pantalla {
+ data object Sesiones : Pantalla
+ data object Grabando : Pantalla
+ data class Ver(val id: Long) : Pantalla
+ data object Ajustes : Pantalla
+}
+
+class VelaViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val repo = Repositorio(app)
+ val ajustes = Ajustes(app)
+
+ var pantalla by mutableStateOf<Pantalla>(Pantalla.Sesiones); private set
+ var permiso by mutableStateOf(false)
+ var errorMic by mutableStateOf<String?>(null); private set
+ var sesiones by mutableStateOf<List<Sesion>>(emptyList()); private set
+ var cfg by mutableStateOf(AjustesActuales()); private set
+
+ private var servicio: VigiliaService? by mutableStateOf(null)
+ val holder: GrabadoraHolder? get() = servicio?.holder
+
+ private var modoPendiente: Modo? = null
+
+ private val conexion = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
+ val svc = (binder as? VigiliaService.LocalBinder)?.service ?: return
+ servicio = svc
+ if (svc.holder.activa) {
+ pantalla = Pantalla.Grabando
+ } else modoPendiente?.let { arrancar(it) }
+ modoPendiente = null
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) { servicio = null }
+ }
+
+ init {
+ val app = getApplication<Application>()
+ app.bindService(
+ Intent(app, VigiliaService::class.java), conexion, Context.BIND_AUTO_CREATE,
+ )
+ recargar()
+ viewModelScope.launch {
+ ajustes.flujo.collectLatest { cfg = it }
+ }
+ }
+
+ fun recargar() {
+ viewModelScope.launch {
+ val lista = withContext(Dispatchers.IO) { repo.listar() }
+ sesiones = lista
+ }
+ }
+
+ // ---- Grabación ----------------------------------------------------
+
+ fun grabar(modo: Modo) {
+ errorMic = null
+ if (!permiso) { errorMic = "hace falta permiso de micrófono"; return }
+ val app = getApplication<Application>()
+ app.startForegroundService(Intent(app, VigiliaService::class.java))
+ val svc = servicio
+ if (svc != null) arrancar(modo) else modoPendiente = modo
+ pantalla = Pantalla.Grabando
+ }
+
+ private fun arrancar(modo: Modo) {
+ servicio?.iniciar(modo) {
+ // fallo al abrir el micrófono (en hilo de fondo): vuelve a la lista
+ viewModelScope.launch {
+ errorMic = "no se pudo abrir el micrófono"
+ pantalla = Pantalla.Sesiones
+ }
+ }
+ }
+
+ fun detener() {
+ val id = holder?.sesionId ?: 0L
+ servicio?.detener()
+ recargar()
+ pantalla = if (id != 0L) Pantalla.Ver(id) else Pantalla.Sesiones
+ }
+
+ // ---- Navegación ---------------------------------------------------
+
+ fun abrir(id: Long) { pantalla = Pantalla.Ver(id) }
+ fun irAjustes() { pantalla = Pantalla.Ajustes }
+ fun volver() {
+ pantalla = if (holder?.activa == true) Pantalla.Grabando else Pantalla.Sesiones
+ }
+
+ fun cargar(id: Long): Sesion? = repo.cargar(id)
+
+ fun borrar(id: Long) {
+ repo.borrar(id)
+ recargar()
+ pantalla = Pantalla.Sesiones
+ }
+
+ // ---- Ajustes ------------------------------------------------------
+
+ fun setSensibilidad(v: Float) = viewModelScope.launch { ajustes.setSensibilidad(v) }
+ fun setSampleRate(v: Int) = viewModelScope.launch { ajustes.setSampleRate(v) }
+ 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) }
+
+ override fun onCleared() {
+ runCatching { getApplication<Application>().unbindService(conexion) }
+ super.onCleared()
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/audio/CodificadorAac.kt b/vela/src/main/java/com/rizoma/vela/audio/CodificadorAac.kt
new file mode 100644
index 0000000..8512bae
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/audio/CodificadorAac.kt
@@ -0,0 +1,134 @@
+package com.rizoma.vela.audio
+
+import android.media.MediaCodec
+import android.media.MediaCodecInfo
+import android.media.MediaFormat
+import android.media.MediaMuxer
+import java.io.File
+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.
+ */
+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()
+
+ private var pista = -1
+ private var muxerArrancado = false
+ private var terminado = false
+
+ /** Muestras (por canal) entregadas hasta ahora → base de los timestamps. */
+ private var muestras = 0L
+
+ 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()
+ }
+
+ /** 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)
+ }
+
+ 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
+ }
+ }
+ }
+ }
+
+ /** 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() }
+ }
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt b/vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt
new file mode 100644
index 0000000..11fcd22
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/audio/Grabadora.kt
@@ -0,0 +1,281 @@
+package com.rizoma.vela.audio
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.media.AudioFormat
+import android.media.AudioRecord
+import android.media.MediaRecorder
+import android.os.Handler
+import android.os.Looper
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+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.Modo
+import com.rizoma.vela.data.Repositorio
+import com.rizoma.vela.data.Segmento
+import kotlin.math.log10
+import kotlin.math.sqrt
+
+enum class EstadoGrabadora { INACTIVA, ESCUCHANDO, GRABANDO }
+
+/**
+ * Estado del motor observable por Compose (mismo proceso, lectura directa).
+ * Vive fuera del ViewModel para sobrevivir a que la app pierda el foco: lo
+ * posee el servicio de primer plano.
+ */
+class GrabadoraHolder {
+ var estado by mutableStateOf(EstadoGrabadora.INACTIVA); internal set
+ var modo by mutableStateOf(Modo.NORMAL); internal set
+ var activa by mutableStateOf(false); internal set
+
+ var sesionId by mutableStateOf(0L); internal set
+ var inicioEpoch by mutableStateOf(0L); internal set
+
+ /** Nivel instantáneo y pico reciente, en dBFS (−70..0) para el medidor. */
+ var nivelDb by mutableStateOf(-70f); internal set
+ var picoDb by mutableStateOf(-70f); internal set
+ /** Umbral de la puerta ahora mismo (línea de disparo del medidor). */
+ var umbralDb by mutableStateOf(-40f); internal set
+
+ var duracionRealMs by mutableStateOf(0L); internal set
+ var duracionSonoraMs by mutableStateOf(0L); internal set
+
+ /** Fragmentos de la sesión en curso (crece en vivo). */
+ val segmentos: SnapshotStateList<Segmento> = mutableStateListOf()
+
+ /** Historial de niveles para la onda; se lee tras suscribirse a [ondasVer]. */
+ val ondas = FloatArray(ANCHO_ONDA) { -70f }
+ var ondasVer by mutableStateOf(0); internal set
+
+ internal fun reset() {
+ segmentos.clear()
+ ondas.fill(-70f)
+ ondasVer = 0
+ nivelDb = -70f; picoDb = -70f
+ duracionRealMs = 0L; duracionSonoraMs = 0L
+ }
+
+ companion object { const val ANCHO_ONDA = 160 }
+}
+
+/**
+ * Motor de grabación inteligente. Captura PCM 16-bit mono con [AudioRecord],
+ * pasa cada cuadro (20 ms) por la [Puerta] de voz y **solo codifica lo que
+ * suena** (compacta silencios). Con pre-roll no corta el arranque de las
+ * palabras y con cola no corta el final. En silencio solo calcula RMS: barato,
+ * ideal para velar de noche ahorrando batería.
+ *
+ * Toda la captura corre en un hilo propio; el estado se publica al hilo
+ * principal para que Compose lo lea.
+ */
+class Grabadora(
+ private val context: Context,
+ private val repo: Repositorio,
+ private val ajustes: AjustesActuales,
+ val holder: GrabadoraHolder,
+ /** Se llama (en el hilo principal) cuando se cierra un fragmento. */
+ private val onSegmento: (Int) -> Unit = {},
+) {
+ private val principal = Handler(Looper.getMainLooper())
+ private val sampleRate = ajustes.sampleRate
+ 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)
+
+ private var record: AudioRecord? = null
+ private var codificador: CodificadorAac? = null
+ private var hilo: Thread? = null
+ @Volatile private var corriendo = false
+
+ private val puerta = Puerta(ajustes.sensibilidad)
+ private val segmentos = ArrayList<Segmento>()
+
+ private var id = 0L
+ private var inicioEpoch = 0L
+
+ /** ¿Se pudo abrir el micrófono? Devuelve false si faltó permiso/hardware. */
+ fun iniciar(modo: Modo): Boolean {
+ if (corriendo) return true
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED
+ ) return false
+
+ val minBuf = AudioRecord.getMinBufferSize(
+ sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
+ )
+ if (minBuf <= 0) return false
+ val bufSize = maxOf(minBuf, frame * 2 * 8)
+
+ val rec = try {
+ @Suppress("MissingPermission")
+ AudioRecord(
+ MediaRecorder.AudioSource.MIC, sampleRate,
+ AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufSize,
+ )
+ } catch (_: Exception) {
+ return false
+ }
+ if (rec.state != AudioRecord.STATE_INITIALIZED) {
+ rec.release(); return false
+ }
+
+ id = System.currentTimeMillis()
+ 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"))
+ record = rec
+ segmentos.clear()
+
+ rec.startRecording()
+ corriendo = true
+
+ holder.reset()
+ holder.modo = modo
+ holder.sesionId = id
+ holder.inicioEpoch = inicioEpoch
+ holder.activa = true
+ holder.estado = EstadoGrabadora.ESCUCHANDO
+
+ hilo = Thread({ bucle(modo) }, "vela-captura").apply { start() }
+ return true
+ }
+
+ fun detener() {
+ if (!corriendo) return
+ corriendo = false
+ hilo?.join(2_000)
+ hilo = null
+ }
+
+ // ---- Bucle de captura (hilo propio) -------------------------------
+
+ private fun bucle(modo: Modo) {
+ val rec = record ?: return
+ val cod = codificador ?: return
+ val buf = ShortArray(frame)
+ val ring = ArrayDeque<ShortArray>() // pre-roll: últimos cuadros de silencio
+
+ var enSonido = false
+ var colaRestante = 0
+ var segInicioReal = 0L
+ var segArchivoUs = 0L
+ var segPico = -120f
+ var segSuma = 0f
+ var segFrames = 0
+ var indice = 0
+ var cuadro = 0
+
+ while (corriendo) {
+ val n = rec.read(buf, 0, frame)
+ if (n <= 0) continue
+ val db = dbDe(buf, n)
+ val activa = puerta.procesar(db)
+
+ if (activa) {
+ colaRestante = colaCuadros
+ if (!enSonido) {
+ // Abrir fragmento: fija tiempos y vuelca el pre-roll (el
+ // arranque de la palabra que estaba en el ring).
+ enSonido = true
+ holder.estado = EstadoGrabadora.GRABANDO
+ segInicioReal = System.currentTimeMillis() - inicioEpoch
+ segArchivoUs = cod.currentFileUs()
+ segPico = -120f; segSuma = 0f; segFrames = 0
+ while (ring.isNotEmpty()) {
+ val f = ring.removeFirst(); cod.feed(f, f.size)
+ }
+ }
+ } else if (enSonido) {
+ if (colaRestante > 0) colaRestante-- else {
+ cerrarSegmento(cod, indice++, segInicioReal, segArchivoUs, segPico, segSuma, segFrames)
+ enSonido = false
+ holder.estado = EstadoGrabadora.ESCUCHANDO
+ }
+ }
+
+ if (enSonido) {
+ cod.feed(buf, n)
+ segFrames++; segSuma += db; if (db > segPico) segPico = db
+ } else {
+ ring.addLast(buf.copyOf(n))
+ while (ring.size > preRollCuadros) ring.removeFirst()
+ }
+
+ // Publicar medidor ~cada 2 cuadros (25 fps) para no saturar el hilo UI.
+ if (cuadro % 2 == 0) publicarMedidor(db, cod.currentFileUs())
+ cuadro++
+ }
+
+ // Cierre: fragmento abierto + EOS del codificador + log final.
+ if (enSonido) {
+ cerrarSegmento(cod, indice, segInicioReal, segArchivoUs, segPico, segSuma, segFrames)
+ }
+ cod.finish()
+ runCatching { rec.stop() }
+ runCatching { rec.release() }
+ record = null
+ codificador = null
+
+ val real = System.currentTimeMillis() - inicioEpoch
+ val sonora = cod.currentFileUs() / 1000
+ repo.escribirLog(id, inicioEpoch, sampleRate, modo, real, sonora, segmentos.toList())
+ principal.post {
+ holder.duracionRealMs = real
+ holder.duracionSonoraMs = sonora
+ holder.activa = false
+ holder.estado = EstadoGrabadora.INACTIVA
+ }
+ }
+
+ private fun cerrarSegmento(
+ cod: CodificadorAac, idx: Int, inicioReal: Long, archivoUs: Long,
+ pico: Float, suma: Float, frames: Int,
+ ) {
+ val durMs = (cod.currentFileUs() - archivoUs) / 1000
+ val medio = if (frames > 0) suma / frames else -70f
+ val seg = Segmento(idx, inicioReal, durMs, archivoUs / 1000, pico, medio)
+ segmentos.add(seg)
+ repo.escribirLog(
+ id, inicioEpoch, sampleRate, holder.modo,
+ System.currentTimeMillis() - inicioEpoch, cod.currentFileUs() / 1000,
+ segmentos.toList(),
+ )
+ principal.post {
+ holder.segmentos.add(seg)
+ holder.duracionSonoraMs = cod.currentFileUs() / 1000
+ onSegmento(segmentos.size)
+ }
+ }
+
+ private fun publicarMedidor(db: Float, fileUs: Long) {
+ val umbral = puerta.umbralDb
+ val real = System.currentTimeMillis() - inicioEpoch
+ principal.post {
+ holder.nivelDb = db
+ holder.umbralDb = umbral
+ holder.picoDb = maxOf(db, holder.picoDb - 1.5f) // decae suave
+ holder.duracionRealMs = real
+ holder.duracionSonoraMs = fileUs / 1000
+ val o = holder.ondas
+ System.arraycopy(o, 1, o, 0, o.size - 1)
+ o[o.size - 1] = db
+ holder.ondasVer++
+ }
+ }
+
+ private fun dbDe(buf: ShortArray, n: Int): Float {
+ var sum = 0.0
+ for (i in 0 until n) {
+ val s = buf[i].toDouble(); sum += s * s
+ }
+ val rms = sqrt(sum / n)
+ val db = 20.0 * log10(rms / 32768.0 + 1e-9)
+ return db.toFloat().coerceIn(-70f, 0f)
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/audio/Puerta.kt b/vela/src/main/java/com/rizoma/vela/audio/Puerta.kt
new file mode 100644
index 0000000..813c2dd
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/audio/Puerta.kt
@@ -0,0 +1,43 @@
+package com.rizoma.vela.audio
+
+/**
+ * Puerta de voz (VAD sencillo y robusto): decide, cuadro a cuadro, si hay
+ * sonido relevante o silencio, sobre un **suelo de ruido adaptativo** con
+ * histéresis (abre alto, cierra bajo) para no castañetear.
+ *
+ * No sabe de pre-roll ni de cola: eso lo añade [Grabadora] alrededor.
+ */
+class Puerta(sensibilidad: Float) {
+
+ /** Estimación del ruido de fondo en dBFS, se adapta sola a la sala. */
+ var sueloDb = -50f
+ private set
+
+ /** Umbral de apertura actual (para dibujar la línea del medidor). */
+ val umbralDb: Float get() = sueloDb + margenApertura
+
+ private var abierta = false
+
+ // Sensibilidad 0..1 → margen de apertura sobre el ruido. Más sensible =
+ // margen menor (basta poco sonido). El cierre siempre es más bajo.
+ private val margenApertura = lerp(16f, 4f, sensibilidad.coerceIn(0f, 1f))
+ private val margenCierre = margenApertura * 0.45f
+
+ /** Devuelve true si en este cuadro hay sonido (tras histéresis). */
+ fun procesar(db: Float): Boolean {
+ if (!abierta && db > sueloDb + margenApertura) {
+ abierta = true
+ } else if (abierta && db < sueloDb + margenCierre) {
+ abierta = false
+ }
+ if (!abierta) {
+ // Solo adaptamos el suelo cuando estamos en silencio, despacio,
+ // para seguir la deriva del ambiente sin tragarnos la voz.
+ sueloDb += (db - sueloDb) * 0.05f
+ sueloDb = sueloDb.coerceIn(-70f, -20f)
+ }
+ return abierta
+ }
+
+ private fun lerp(a: Float, b: Float, t: Float) = a + (b - a) * t
+}
diff --git a/vela/src/main/java/com/rizoma/vela/audio/Reproductor.kt b/vela/src/main/java/com/rizoma/vela/audio/Reproductor.kt
new file mode 100644
index 0000000..4515915
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/audio/Reproductor.kt
@@ -0,0 +1,67 @@
+package com.rizoma.vela.audio
+
+import android.media.MediaPlayer
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import java.io.File
+
+/**
+ * Reproductor sencillo sobre [MediaPlayer] para escuchar el audio compactado
+ * de una sesión y saltar a la posición de un fragmento. Estado observable por
+ * Compose.
+ */
+class Reproductor {
+ private var mp: MediaPlayer? = null
+
+ var sonando by mutableStateOf(false); private set
+ var posicionMs by mutableStateOf(0); private set
+ var duracionMs by mutableStateOf(0); private set
+ var listo by mutableStateOf(false); private set
+
+ fun cargar(archivo: File) {
+ soltar()
+ if (!archivo.exists()) return
+ mp = MediaPlayer().apply {
+ setDataSource(archivo.absolutePath)
+ setOnPreparedListener {
+ this@Reproductor.duracionMs = it.duration
+ listo = true
+ }
+ setOnCompletionListener {
+ sonando = false
+ posicionMs = 0
+ seekTo(0)
+ }
+ prepareAsync()
+ }
+ }
+
+ /** Salta a [ms] dentro del audio y empieza a sonar. */
+ fun irA(ms: Int) {
+ val p = mp ?: return
+ p.seekTo(ms)
+ posicionMs = ms
+ if (!p.isPlaying) { p.start(); sonando = true }
+ }
+
+ fun alternar() {
+ val p = mp ?: return
+ if (p.isPlaying) { p.pause(); sonando = false }
+ else { p.start(); sonando = true }
+ }
+
+ /** Debe llamarse periódicamente mientras suena para refrescar la posición. */
+ fun sincronizar() {
+ val p = mp ?: return
+ if (p.isPlaying) posicionMs = p.currentPosition
+ }
+
+ fun soltar() {
+ mp?.let { runCatching { it.release() } }
+ mp = null
+ sonando = false
+ listo = false
+ posicionMs = 0
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/data/Ajustes.kt b/vela/src/main/java/com/rizoma/vela/data/Ajustes.kt
new file mode 100644
index 0000000..8581c9d
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/data/Ajustes.kt
@@ -0,0 +1,57 @@
+package com.rizoma.vela.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+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.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore: DataStore<Preferences> by preferencesDataStore("vela")
+
+/** Copia inmutable de los ajustes para arrancar el motor sin depender del flujo. */
+data class AjustesActuales(
+ val sensibilidad: Float = 0.5f,
+ val sampleRate: Int = 16_000,
+ val preRollMs: Int = 300,
+ val colaMs: Int = 800,
+ val mantenerPantalla: Boolean = false,
+)
+
+/**
+ * Ajustes del motor, en DataStore. `sensibilidad` gobierna la puerta de voz;
+ * `preRollMs`/`colaMs` evitan cortar el arranque y la cola de las palabras.
+ */
+class Ajustes(private val context: Context) {
+
+ val flujo: Flow<AjustesActuales> = context.dataStore.data.map { p ->
+ AjustesActuales(
+ sensibilidad = p[SENSIBILIDAD] ?: 0.5f,
+ sampleRate = p[SAMPLE_RATE] ?: 16_000,
+ preRollMs = p[PREROLL] ?: 300,
+ colaMs = p[COLA] ?: 800,
+ mantenerPantalla = p[PANTALLA] ?: false,
+ )
+ }
+
+ suspend fun actuales(): AjustesActuales = flujo.first()
+
+ suspend fun setSensibilidad(v: Float) = context.dataStore.edit { it[SENSIBILIDAD] = v }
+ suspend fun setSampleRate(v: Int) = context.dataStore.edit { it[SAMPLE_RATE] = v }
+ 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 }
+
+ private companion object {
+ val SENSIBILIDAD = floatPreferencesKey("sensibilidad")
+ val SAMPLE_RATE = intPreferencesKey("sample_rate")
+ val PREROLL = intPreferencesKey("preroll_ms")
+ val COLA = intPreferencesKey("cola_ms")
+ val PANTALLA = booleanPreferencesKey("mantener_pantalla")
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/data/Repositorio.kt b/vela/src/main/java/com/rizoma/vela/data/Repositorio.kt
new file mode 100644
index 0000000..869363a
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/data/Repositorio.kt
@@ -0,0 +1,103 @@
+package com.rizoma.vela.data
+
+import android.content.Context
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * Guarda y lee las sesiones en `filesDir/sesiones/<id>/`. Cada sesión es una
+ * carpeta con `audio.m4a` y `log.json`. Sin base de datos ni deps: JSON crudo
+ * con escritura atómica (`.tmp` + rename), como el resto del rizoma.
+ */
+class Repositorio(context: Context) {
+
+ private val base = File(context.filesDir, "sesiones")
+
+ fun carpeta(id: Long): File = File(base, id.toString()).apply { mkdirs() }
+
+ /** Escribe (o reescribe) el log de una sesión de forma atómica. */
+ fun escribirLog(
+ id: Long,
+ inicioEpoch: Long,
+ sampleRate: Int,
+ modo: Modo,
+ duracionRealMs: Long,
+ duracionSonoraMs: Long,
+ segmentos: List<Segmento>,
+ ) {
+ val root = JSONObject().apply {
+ put("id", id)
+ put("inicioEpoch", inicioEpoch)
+ put("sampleRate", sampleRate)
+ put("modo", modo.name)
+ put("duracionRealMs", duracionRealMs)
+ put("duracionSonoraMs", duracionSonoraMs)
+ put("segmentos", JSONArray().apply {
+ segmentos.forEach { s ->
+ put(JSONObject().apply {
+ put("i", s.indice)
+ put("ini", s.inicioRealMs)
+ put("dur", s.duracionMs)
+ put("arch", s.archivoMs)
+ put("pico", s.picoDb.toDouble())
+ put("medio", s.medioDb.toDouble())
+ })
+ }
+ })
+ }
+ val dir = carpeta(id)
+ val tmp = File(dir, "log.json.tmp")
+ tmp.writeText(root.toString())
+ tmp.renameTo(File(dir, "log.json"))
+ }
+
+ /** Lista las sesiones (más recientes primero), sin cargar los segmentos. */
+ fun listar(): List<Sesion> {
+ val dirs = base.listFiles()?.filter { it.isDirectory } ?: return emptyList()
+ return dirs.mapNotNull { cargarDe(it) }
+ .sortedByDescending { it.inicioEpoch }
+ }
+
+ fun cargar(id: Long): Sesion? = cargarDe(File(base, id.toString()))
+
+ private fun cargarDe(dir: File): Sesion? {
+ val log = File(dir, "log.json")
+ if (!log.exists()) return null
+ return try {
+ val o = JSONObject(log.readText())
+ val arr = o.optJSONArray("segmentos") ?: JSONArray()
+ val segs = ArrayList<Segmento>(arr.length())
+ for (i in 0 until arr.length()) {
+ val s = arr.getJSONObject(i)
+ segs.add(
+ Segmento(
+ indice = s.optInt("i", i),
+ inicioRealMs = s.optLong("ini"),
+ duracionMs = s.optLong("dur"),
+ archivoMs = s.optLong("arch"),
+ picoDb = s.optDouble("pico", -70.0).toFloat(),
+ medioDb = s.optDouble("medio", -70.0).toFloat(),
+ )
+ )
+ }
+ Sesion(
+ id = o.getLong("id"),
+ inicioEpoch = o.getLong("inicioEpoch"),
+ sampleRate = o.optInt("sampleRate", 16000),
+ modo = runCatching { Modo.valueOf(o.optString("modo", "NORMAL")) }
+ .getOrDefault(Modo.NORMAL),
+ duracionRealMs = o.optLong("duracionRealMs"),
+ duracionSonoraMs = o.optLong("duracionSonoraMs"),
+ segmentos = segs,
+ dir = dir,
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ fun borrar(id: Long) {
+ File(base, id.toString()).deleteRecursively()
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/data/Sesion.kt b/vela/src/main/java/com/rizoma/vela/data/Sesion.kt
new file mode 100644
index 0000000..d7450c7
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/data/Sesion.kt
@@ -0,0 +1,48 @@
+package com.rizoma.vela.data
+
+import java.io.File
+
+/**
+ * Un fragmento con sonido dentro de una sesión. Los silencios entre fragmentos
+ * NO se guardan en el audio (se compactan); el hueco real se deduce restando.
+ */
+data class Segmento(
+ /** Orden dentro de la sesión (0..n). */
+ val indice: Int,
+ /** Inicio del fragmento en tiempo real, ms desde el comienzo de la sesión. */
+ val inicioRealMs: Long,
+ /** Duración sonora del fragmento en ms (lo que dura al reproducirlo). */
+ val duracionMs: Long,
+ /** Posición de este fragmento dentro del audio compactado, en ms. */
+ val archivoMs: Long,
+ /** Pico de nivel del fragmento, en dBFS (−70..0). */
+ val picoDb: Float,
+ /** Nivel medio del fragmento, en dBFS. */
+ val medioDb: Float,
+)
+
+/** Modo con el que se grabó/vela una sesión. */
+enum class Modo { NORMAL, VIGILIA }
+
+/**
+ * Una sesión = una carpeta con `audio.m4a` (audio compactado) y `log.json`
+ * (la línea de tiempo del diálogo).
+ */
+data class Sesion(
+ val id: Long,
+ val inicioEpoch: Long,
+ val sampleRate: Int,
+ val modo: Modo,
+ /** Duración real de reloj de toda la sesión, ms. */
+ val duracionRealMs: Long,
+ /** Duración del audio grabado (sin silencios), ms. */
+ val duracionSonoraMs: Long,
+ val segmentos: List<Segmento>,
+ val dir: File,
+) {
+ val audio: File get() = File(dir, "audio.m4a")
+
+ /** Fracción de la noche que hubo sonido: útil para ver de un vistazo. */
+ val fraccionSonora: Float
+ get() = if (duracionRealMs > 0) (duracionSonoraMs.toFloat() / duracionRealMs) else 0f
+}
diff --git a/vela/src/main/java/com/rizoma/vela/service/VigiliaService.kt b/vela/src/main/java/com/rizoma/vela/service/VigiliaService.kt
new file mode 100644
index 0000000..251d407
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/service/VigiliaService.kt
@@ -0,0 +1,191 @@
+package com.rizoma.vela.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.Manifest
+import android.app.Service
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.os.Binder
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+import com.rizoma.vela.MainActivity
+import com.rizoma.vela.R
+import com.rizoma.vela.audio.Grabadora
+import com.rizoma.vela.audio.GrabadoraHolder
+import com.rizoma.vela.data.Ajustes
+import com.rizoma.vela.data.Modo
+import com.rizoma.vela.data.Repositorio
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+/**
+ * Servicio de primer plano que **posee el motor de grabación**. Mientras vela,
+ * corre en primer plano (tipo micrófono) con una notificación persistente y un
+ * wakelock parcial, así Android no mata el proceso —ni la escucha— con la
+ * pantalla apagada. La UI se une (bind) para leer el estado y arrancar/parar.
+ */
+class VigiliaService : Service() {
+
+ inner class LocalBinder : Binder() {
+ val service: VigiliaService get() = this@VigiliaService
+ }
+
+ private val binder = LocalBinder()
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ /** Estado observable del motor; la UI lo lee directamente (mismo proceso). */
+ val holder = GrabadoraHolder()
+
+ private var grabadora: Grabadora? = null
+ private var wakelock: PowerManager.WakeLock? = null
+
+ override fun onBind(intent: Intent?): IBinder = binder
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_STOP) detener()
+ return START_NOT_STICKY
+ }
+
+ /**
+ * Arranca una sesión. Lee los ajustes frescos, entra a primer plano, toma
+ * el wakelock y abre el micrófono. Devuelve false si faltó el permiso o el
+ * hardware (la UI lo refleja).
+ */
+ fun iniciar(modo: Modo, alFallar: () -> Unit) {
+ if (holder.activa) return
+ // En Android 14+ startForeground(microphone) exige RECORD_AUDIO: si no
+ // está, ni entramos a primer plano (evita SecurityException).
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED
+ ) { alFallar(); return }
+ scope.launch {
+ val cfg = Ajustes(this@VigiliaService).actuales()
+ val repo = Repositorio(this@VigiliaService)
+ ensureChannel()
+ enterForeground(modo, 0)
+ adquirirWakelock()
+ val motor = Grabadora(
+ this@VigiliaService, repo, cfg, holder,
+ onSegmento = { n -> refrescarNotificacion(modo, n) },
+ )
+ grabadora = motor
+ val ok = motor.iniciar(modo)
+ if (!ok) {
+ grabadora = null
+ soltarTodo()
+ alFallar()
+ }
+ }
+ }
+
+ fun detener() {
+ grabadora?.detener()
+ grabadora = null
+ soltarTodo()
+ }
+
+ private fun soltarTodo() {
+ soltarWakelock()
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ // ---- Wakelock -----------------------------------------------------
+
+ private fun adquirirWakelock() {
+ if (wakelock != null) return
+ val pm = getSystemService(PowerManager::class.java)
+ wakelock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "vela:vigilia").apply {
+ setReferenceCounted(false)
+ acquire()
+ }
+ }
+
+ private fun soltarWakelock() {
+ wakelock?.let { if (it.isHeld) it.release() }
+ wakelock = null
+ }
+
+ // ---- Notificación de primer plano ---------------------------------
+
+ private fun enterForeground(modo: Modo, fragmentos: Int) {
+ val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+ } else 0
+ ServiceCompat.startForeground(this, NOTIF_ID, notificacion(modo, fragmentos), type)
+ }
+
+ private fun refrescarNotificacion(modo: Modo, fragmentos: Int) {
+ if (!holder.activa) return
+ getSystemService(NotificationManager::class.java)
+ .notify(NOTIF_ID, notificacion(modo, fragmentos))
+ }
+
+ private fun ensureChannel() {
+ val mgr = getSystemService(NotificationManager::class.java)
+ if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
+ mgr.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID, "vela activa", NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "graba/vela con la pantalla apagada"
+ setShowBadge(false)
+ }
+ )
+ }
+ }
+
+ private fun notificacion(modo: Modo, fragmentos: Int): Notification {
+ val flags = PendingIntent.FLAG_IMMUTABLE
+ val abrir = PendingIntent.getActivity(
+ this, 0,
+ Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ flags,
+ )
+ val parar = PendingIntent.getService(
+ this, 1,
+ Intent(this, VigiliaService::class.java).setAction(ACTION_STOP),
+ flags,
+ )
+ val titulo = if (modo == Modo.VIGILIA) "[ velando la noche ]" else "[ grabando ]"
+ val sub = if (fragmentos > 0) "$fragmentos fragmentos con sonido" else "escuchando…"
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_vela)
+ .setContentTitle(titulo)
+ .setContentText(sub)
+ .setColor(NEON_VERDE)
+ .setOngoing(true)
+ .setShowWhen(false)
+ .setSilent(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setContentIntent(abrir)
+ .addAction(0, "detener", parar)
+ .build()
+ }
+
+ override fun onDestroy() {
+ grabadora?.detener()
+ soltarWakelock()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "vela_activa"
+ private const val NOTIF_ID = 0x7E1A // "vela"
+ private const val NEON_VERDE = 0xFF7CFF4F.toInt()
+ const val ACTION_STOP = "com.rizoma.vela.action.STOP"
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/AjustesScreen.kt b/vela/src/main/java/com/rizoma/vela/ui/AjustesScreen.kt
new file mode 100644
index 0000000..2dff372
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/AjustesScreen.kt
@@ -0,0 +1,146 @@
+package com.rizoma.vela.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+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.ui.theme.MuyTenue
+import com.rizoma.vela.ui.theme.NeonCian
+import com.rizoma.vela.ui.theme.NeonVerde
+import com.rizoma.vela.ui.theme.Superficie
+import com.rizoma.vela.ui.theme.Tenue
+import com.rizoma.vela.ui.theme.Vacio
+
+@Composable
+fun AjustesScreen(
+ cfg: AjustesActuales,
+ onSensibilidad: (Float) -> Unit,
+ onSampleRate: (Int) -> Unit,
+ onPreRoll: (Int) -> Unit,
+ onCola: (Int) -> Unit,
+ onMantenerPantalla: (Boolean) -> Unit,
+ onVolver: () -> Unit,
+) {
+ Column(
+ Modifier.fillMaxSize().padding(20.dp).verticalScroll(rememberScrollState()),
+ ) {
+ Text(
+ "‹ volver", color = Tenue, fontSize = 15.sp,
+ modifier = Modifier.clickable { onVolver() }.padding(top = 12.dp, bottom = 4.dp),
+ )
+ Text("ajustes", color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(20.dp))
+
+ Deslizador(
+ "sensibilidad", "cuánto sonido hace falta para disparar la grabación",
+ cfg.sensibilidad, 0f..1f, "%.0f%%".format(cfg.sensibilidad * 100),
+ onSensibilidad,
+ )
+
+ Deslizador(
+ "pre-roll", "cuánto se guarda ANTES de detectar sonido (no corta el arranque)",
+ cfg.preRollMs.toFloat(), 0f..600f, "${cfg.preRollMs} ms",
+ ) { onPreRoll(it.toInt()) }
+
+ Deslizador(
+ "cola", "cuánto se sigue grabando tras el último sonido (no corta el final)",
+ cfg.colaMs.toFloat(), 200f..2000f, "${cfg.colaMs} ms",
+ ) { onCola(it.toInt()) }
+
+ Spacer(Modifier.height(8.dp))
+ Text("calidad", color = Tenue, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+ Text(
+ "16 kHz mono basta para voz y gasta menos batería/espacio",
+ color = MuyTenue, fontSize = 12.sp,
+ )
+ Spacer(Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OpcionSR("16 kHz · voz", 16_000, cfg.sampleRate, onSampleRate)
+ OpcionSR("44.1 kHz · alta", 44_100, cfg.sampleRate, onSampleRate)
+ }
+
+ Spacer(Modifier.height(20.dp))
+ Row(
+ Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text("mantener pantalla encendida", color = Tenue, fontSize = 15.sp)
+ Text(
+ "durante la sesión en vivo (la vigilia no la necesita)",
+ color = MuyTenue, fontSize = 12.sp,
+ )
+ }
+ Switch(
+ checked = cfg.mantenerPantalla, onCheckedChange = onMantenerPantalla,
+ colors = SwitchDefaults.colors(
+ checkedTrackColor = NeonVerde, checkedThumbColor = Vacio,
+ ),
+ )
+ }
+ Spacer(Modifier.height(24.dp))
+ }
+}
+
+@Composable
+private fun Deslizador(
+ titulo: String, sub: String, valor: Float, rango: ClosedFloatingPointRange<Float>,
+ etiqueta: String, onChange: (Float) -> Unit,
+) {
+ Column(Modifier.padding(bottom = 16.dp)) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(titulo, color = Tenue, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+ Text(etiqueta, color = NeonCian, fontSize = 14.sp, fontFamily = FontFamily.Monospace)
+ }
+ Text(sub, color = MuyTenue, fontSize = 12.sp)
+ Slider(
+ value = valor, onValueChange = onChange, valueRange = rango,
+ colors = SliderDefaults.colors(
+ thumbColor = NeonVerde, activeTrackColor = NeonVerde,
+ ),
+ )
+ }
+}
+
+@Composable
+private fun OpcionSR(texto: String, sr: Int, actual: Int, onClick: (Int) -> Unit) {
+ val sel = sr == actual
+ Text(
+ texto,
+ color = if (sel) Vacio else Tenue, fontSize = 13.sp,
+ 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(sr) }
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ )
+}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/Formato.kt b/vela/src/main/java/com/rizoma/vela/ui/Formato.kt
new file mode 100644
index 0000000..28e3a8c
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/Formato.kt
@@ -0,0 +1,45 @@
+package com.rizoma.vela.ui
+
+import java.util.Calendar
+
+/** ms → "m:ss" o "h:mm:ss". */
+fun duracion(ms: Long): String {
+ val s = ms / 1000
+ val h = s / 3600
+ val m = (s % 3600) / 60
+ val seg = s % 60
+ return if (h > 0) "%d:%02d:%02d".format(h, m, seg) else "%d:%02d".format(m, seg)
+}
+
+/** Duración larga, humana: "2 h 14 min", "3 min", "12 s". */
+fun duracionLarga(ms: Long): String {
+ val s = ms / 1000
+ val h = s / 3600
+ val m = (s % 3600) / 60
+ val seg = s % 60
+ return when {
+ h > 0 -> "$h h ${m} min"
+ m > 0 -> "$m min ${seg} s"
+ else -> "$seg s"
+ }
+}
+
+/** epoch ms → hora del día "HH:mm:ss". */
+fun horaDe(epochMs: Long): String {
+ val c = Calendar.getInstance().apply { timeInMillis = epochMs }
+ return "%02d:%02d:%02d".format(
+ c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE), c.get(Calendar.SECOND),
+ )
+}
+
+/** epoch ms → fecha y hora "d/M HH:mm". */
+fun fechaDe(epochMs: Long): String {
+ val c = Calendar.getInstance().apply { timeInMillis = epochMs }
+ return "%d/%d · %02d:%02d".format(
+ c.get(Calendar.DAY_OF_MONTH), c.get(Calendar.MONTH) + 1,
+ c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE),
+ )
+}
+
+/** Nivel dBFS (−70..0) → fracción 0..1 para barras. */
+fun nivelFrac(db: Float): Float = ((db + 70f) / 70f).coerceIn(0f, 1f)
diff --git a/vela/src/main/java/com/rizoma/vela/ui/GrabandoScreen.kt b/vela/src/main/java/com/rizoma/vela/ui/GrabandoScreen.kt
new file mode 100644
index 0000000..4eb5fc8
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/GrabandoScreen.kt
@@ -0,0 +1,147 @@
+package com.rizoma.vela.ui
+
+import android.view.WindowManager
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.vela.audio.EstadoGrabadora
+import com.rizoma.vela.audio.GrabadoraHolder
+import com.rizoma.vela.data.Modo
+import com.rizoma.vela.ui.theme.MuyTenue
+import com.rizoma.vela.ui.theme.NeonCian
+import com.rizoma.vela.ui.theme.NeonRosa
+import com.rizoma.vela.ui.theme.NeonVerde
+import com.rizoma.vela.ui.theme.Superficie
+import com.rizoma.vela.ui.theme.Tenue
+
+@Composable
+fun GrabandoScreen(
+ holder: GrabadoraHolder?,
+ mantenerPantalla: Boolean,
+ onDetener: () -> Unit,
+) {
+ if (holder == null) {
+ Box(Modifier.fillMaxSize(), Alignment.Center) {
+ Text("iniciando…", color = Tenue)
+ }
+ return
+ }
+
+ // Mantener la pantalla encendida si el ajuste lo pide (sesión en vivo).
+ if (mantenerPantalla) {
+ val ctx = LocalContext.current
+ DisposableEffect(Unit) {
+ val w = (ctx as? android.app.Activity)?.window
+ w?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ onDispose { w?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) }
+ }
+ }
+
+ val grabando = holder.estado == EstadoGrabadora.GRABANDO
+ val esVigilia = holder.modo == Modo.VIGILIA
+ val acento = if (grabando) NeonRosa else NeonVerde
+
+ Column(
+ Modifier.fillMaxSize().padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(Modifier.height(24.dp))
+ Text(
+ if (esVigilia) "velando la noche" else "grabando",
+ color = Tenue, fontSize = 14.sp, letterSpacing = 3.sp,
+ )
+ Spacer(Modifier.height(4.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier.size(9.dp).clip(CircleShape)
+ .background(if (grabando) NeonRosa else NeonCian),
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(
+ if (grabando) "sonido detectado" else "en silencio · escuchando",
+ color = if (grabando) NeonRosa else NeonCian, fontSize = 13.sp,
+ )
+ }
+
+ Spacer(Modifier.height(28.dp))
+ Medidor(holder, Modifier.fillMaxWidth().height(140.dp))
+
+ Spacer(Modifier.height(24.dp))
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ Metrica("tiempo real", duracion(holder.duracionRealMs), Tenue)
+ Metrica("con sonido", duracion(holder.duracionSonoraMs), NeonVerde)
+ Metrica("fragmentos", holder.segmentos.size.toString(), NeonCian)
+ }
+
+ Spacer(Modifier.height(20.dp))
+ Text(
+ "log de diálogo",
+ color = MuyTenue, fontSize = 12.sp, letterSpacing = 2.sp,
+ modifier = Modifier.align(Alignment.Start),
+ )
+ Spacer(Modifier.height(8.dp))
+ Box(
+ Modifier.fillMaxWidth().weight(1f).clip(RoundedCornerShape(12.dp))
+ .background(Superficie).padding(10.dp),
+ ) {
+ RegistroDialogo(
+ segmentos = holder.segmentos,
+ inicioEpoch = holder.inicioEpoch,
+ modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
+ )
+ }
+
+ Spacer(Modifier.height(20.dp))
+ Box(
+ Modifier.size(76.dp).clip(CircleShape)
+ .border(2.dp, acento, CircleShape)
+ .clickable { onDetener() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(Modifier.size(28.dp).clip(RoundedCornerShape(6.dp)).background(acento))
+ }
+ Spacer(Modifier.height(6.dp))
+ Text("detener", color = Tenue, fontSize = 12.sp)
+ Spacer(Modifier.height(12.dp))
+ }
+}
+
+@Composable
+private fun Metrica(etiqueta: String, valor: String, color: Color) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ valor, color = color, fontSize = 22.sp,
+ fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace,
+ )
+ Text(etiqueta, color = MuyTenue, fontSize = 11.sp)
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/Medidor.kt b/vela/src/main/java/com/rizoma/vela/ui/Medidor.kt
new file mode 100644
index 0000000..5fc2021
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/Medidor.kt
@@ -0,0 +1,66 @@
+package com.rizoma.vela.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.ui.Modifier
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import com.rizoma.vela.audio.GrabadoraHolder
+import com.rizoma.vela.ui.theme.NeonCian
+import com.rizoma.vela.ui.theme.NeonRosa
+import com.rizoma.vela.ui.theme.NeonVerde
+
+/**
+ * Onda en vivo: el historial de niveles del [holder] dibujado como barras
+ * espejadas, con la línea del umbral de la puerta. Verde cuando escucha, rosa
+ * cuando el nivel cruza el umbral (está capturando).
+ */
+@Composable
+fun Medidor(holder: GrabadoraHolder, modifier: Modifier = Modifier) {
+ // Suscripción al reloj de la onda: el array muta en sitio, así que
+ // dependemos del contador para redibujar cuando llega un cuadro nuevo.
+ val version = holder.ondasVer
+ Canvas(modifier) {
+ version.let { }
+ val o = holder.ondas
+ val n = o.size
+ val medio = size.height / 2f
+ val paso = size.width / n
+ val umbralY = medio - nivelFrac(holder.umbralDb) * medio
+
+ // Línea del umbral de disparo.
+ drawLine(
+ color = Color(0x55FFFFFF),
+ start = Offset(0f, umbralY),
+ end = Offset(size.width, umbralY),
+ strokeWidth = 1.5f,
+ )
+ drawLine(
+ color = Color(0x55FFFFFF),
+ start = Offset(0f, size.height - umbralY),
+ end = Offset(size.width, size.height - umbralY),
+ strokeWidth = 1.5f,
+ )
+
+ for (i in 0 until n) {
+ val db = o[i]
+ val f = nivelFrac(db)
+ val h = f * medio
+ val x = i * paso + paso / 2f
+ val cruza = db > holder.umbralDb
+ val c = when {
+ cruza && f > 0.8f -> NeonRosa
+ cruza -> NeonVerde
+ else -> NeonCian.copy(alpha = 0.35f)
+ }
+ drawLine(
+ color = c,
+ start = Offset(x, medio - h),
+ end = Offset(x, medio + h),
+ strokeWidth = paso * 0.6f,
+ cap = StrokeCap.Round,
+ )
+ }
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/RegistroDialogo.kt b/vela/src/main/java/com/rizoma/vela/ui/RegistroDialogo.kt
new file mode 100644
index 0000000..a710874
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/RegistroDialogo.kt
@@ -0,0 +1,130 @@
+package com.rizoma.vela.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.vela.data.Segmento
+import com.rizoma.vela.ui.theme.MuyTenue
+import com.rizoma.vela.ui.theme.NeonCian
+import com.rizoma.vela.ui.theme.NeonRosa
+import com.rizoma.vela.ui.theme.NeonVerde
+import com.rizoma.vela.ui.theme.SuperficieAlta
+import com.rizoma.vela.ui.theme.Tenue
+
+/** Umbral para considerar que un silencio merece marca propia en el log. */
+private const val SILENCIO_MIN_MS = 4_000L
+
+/**
+ * La línea de tiempo del diálogo: cada fragmento con sonido como una fila y los
+ * silencios largos colapsados entre ellos ("· 2 h 14 min en silencio ·"). Al
+ * tocar un fragmento salta a su posición en el audio (si hay reproductor).
+ */
+@Composable
+fun RegistroDialogo(
+ segmentos: List<Segmento>,
+ inicioEpoch: Long,
+ modifier: Modifier = Modifier,
+ activo: Int = -1,
+ onTap: ((Segmento) -> Unit)? = null,
+) {
+ Column(modifier) {
+ if (segmentos.isEmpty()) {
+ Text(
+ "aún no hay sonido — todo en silencio",
+ color = MuyTenue, fontSize = 13.sp,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+ }
+ var previoFin = 0L
+ segmentos.forEach { s ->
+ val hueco = s.inicioRealMs - previoFin
+ if (hueco >= SILENCIO_MIN_MS && previoFin > 0L) {
+ MarcaSilencio(hueco)
+ }
+ FilaFragmento(
+ s = s,
+ inicioEpoch = inicioEpoch,
+ activo = s.indice == activo,
+ onTap = onTap,
+ )
+ previoFin = s.inicioRealMs + s.duracionMs
+ }
+ }
+}
+
+@Composable
+private fun MarcaSilencio(ms: Long) {
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Box(Modifier.weight(1f).height(1.dp).background(Color(0x22FFFFFF)))
+ Text(
+ " · ${duracionLarga(ms)} en silencio · ",
+ color = MuyTenue, fontSize = 11.sp,
+ )
+ Box(Modifier.weight(1f).height(1.dp).background(Color(0x22FFFFFF)))
+ }
+}
+
+@Composable
+private fun FilaFragmento(
+ s: Segmento,
+ inicioEpoch: Long,
+ activo: Boolean,
+ onTap: ((Segmento) -> Unit)?,
+) {
+ val fondo = if (activo) Color(0x2200E5FF) else SuperficieAlta
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 3.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(fondo)
+ .then(if (onTap != null) Modifier.clickable { onTap(s) } else Modifier)
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ horaDe(inicioEpoch + s.inicioRealMs),
+ color = if (activo) NeonCian else Tenue,
+ fontSize = 13.sp, fontFamily = FontFamily.Monospace,
+ )
+ Spacer(Modifier.width(12.dp))
+ // barra de nivel del fragmento (pico)
+ Box(
+ Modifier.weight(1f).height(6.dp).clip(RoundedCornerShape(3.dp))
+ .background(Color(0x14FFFFFF)),
+ ) {
+ val f = nivelFrac(s.picoDb)
+ Box(
+ Modifier.fillMaxWidth(f).height(6.dp).clip(RoundedCornerShape(3.dp))
+ .background(if (f > 0.75f) NeonRosa else NeonVerde),
+ )
+ }
+ Spacer(Modifier.width(12.dp))
+ Text(
+ duracion(s.duracionMs),
+ color = Tenue, fontSize = 13.sp, fontFamily = FontFamily.Monospace,
+ )
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/SesionScreen.kt b/vela/src/main/java/com/rizoma/vela/ui/SesionScreen.kt
new file mode 100644
index 0000000..32ba5f0
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/SesionScreen.kt
@@ -0,0 +1,171 @@
+package com.rizoma.vela.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.vela.audio.Reproductor
+import com.rizoma.vela.data.Modo
+import com.rizoma.vela.data.Sesion
+import com.rizoma.vela.ui.theme.MuyTenue
+import com.rizoma.vela.ui.theme.NeonCian
+import com.rizoma.vela.ui.theme.NeonRosa
+import com.rizoma.vela.ui.theme.NeonVerde
+import com.rizoma.vela.ui.theme.Superficie
+import com.rizoma.vela.ui.theme.Tenue
+import kotlinx.coroutines.delay
+
+@Composable
+fun SesionScreen(
+ sesion: Sesion,
+ onVolver: () -> Unit,
+ onBorrar: (Long) -> Unit,
+) {
+ val repro = remember { Reproductor() }
+ var activo by remember { mutableStateOf(-1) }
+
+ DisposableEffect(sesion.id) {
+ repro.cargar(sesion.audio)
+ onDispose { repro.soltar() }
+ }
+ // Refresca la posición mientras suena.
+ LaunchedEffect(repro.sonando) {
+ while (repro.sonando) { repro.sincronizar(); delay(200) }
+ }
+ // Marca activo el fragmento cuya ventana contiene la posición actual.
+ val pos = repro.posicionMs.toLong()
+ LaunchedEffect(pos) {
+ activo = sesion.segmentos.firstOrNull {
+ pos >= it.archivoMs && pos < it.archivoMs + it.duracionMs
+ }?.indice ?: activo
+ }
+
+ Column(Modifier.fillMaxSize().padding(20.dp)) {
+ Row(
+ Modifier.fillMaxWidth().padding(top = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ "‹ volver", color = Tenue, fontSize = 15.sp,
+ modifier = Modifier.clickable { onVolver() }.padding(6.dp),
+ )
+ Text(
+ "borrar", color = NeonRosa, fontSize = 13.sp,
+ modifier = Modifier.clickable { onBorrar(sesion.id) }.padding(6.dp),
+ )
+ }
+
+ Spacer(Modifier.height(8.dp))
+ Text(fechaDe(sesion.inicioEpoch), color = Color.White, fontSize = 22.sp,
+ fontWeight = FontWeight.Bold)
+ Text(
+ (if (sesion.modo == Modo.VIGILIA) "vigilia nocturna" else "grabación") +
+ " · ${sesion.sampleRate / 1000} kHz",
+ color = if (sesion.modo == Modo.VIGILIA) NeonCian else NeonVerde, fontSize = 13.sp,
+ )
+
+ Spacer(Modifier.height(16.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
+ Dato("real", duracion(sesion.duracionRealMs))
+ Dato("con sonido", duracion(sesion.duracionSonoraMs))
+ Dato("fragmentos", sesion.segmentos.size.toString())
+ }
+
+ // Barra de reproducción del audio compactado.
+ Spacer(Modifier.height(16.dp))
+ Reproduccion(repro)
+
+ Spacer(Modifier.height(16.dp))
+ Text("log de diálogo", color = MuyTenue, fontSize = 12.sp, letterSpacing = 2.sp)
+ Spacer(Modifier.height(8.dp))
+ Box(
+ Modifier.fillMaxWidth().weight(1f).clip(RoundedCornerShape(12.dp))
+ .background(Superficie).padding(10.dp),
+ ) {
+ RegistroDialogo(
+ segmentos = sesion.segmentos,
+ inicioEpoch = sesion.inicioEpoch,
+ activo = activo,
+ modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
+ onTap = { repro.irA(it.archivoMs.toInt()) },
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+}
+
+@Composable
+private fun Reproduccion(repro: Reproductor) {
+ Row(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp))
+ .background(Superficie).padding(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier.size(48.dp).clip(CircleShape)
+ .border(2.dp, NeonVerde, CircleShape)
+ .clickable(enabled = repro.listo) { repro.alternar() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(if (repro.sonando) "❚❚" else "▶", color = NeonVerde, fontSize = 16.sp)
+ }
+ Spacer(Modifier.size(14.dp))
+ Column(Modifier.weight(1f)) {
+ val dur = repro.duracionMs.coerceAtLeast(1)
+ val f = repro.posicionMs.toFloat() / dur
+ Box(
+ Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))
+ .background(Color(0x14FFFFFF)),
+ ) {
+ Box(
+ Modifier.fillMaxWidth(f.coerceIn(0f, 1f)).height(6.dp)
+ .clip(RoundedCornerShape(3.dp)).background(NeonVerde),
+ )
+ }
+ Spacer(Modifier.height(6.dp))
+ Text(
+ "${duracion(repro.posicionMs.toLong())} / ${duracion(repro.duracionMs.toLong())}",
+ color = Tenue, fontSize = 12.sp, fontFamily = FontFamily.Monospace,
+ )
+ }
+ }
+}
+
+@Composable
+private fun Dato(etiqueta: String, valor: String) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(valor, color = Color.White, fontSize = 18.sp,
+ fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
+ Text(etiqueta, color = MuyTenue, fontSize = 11.sp)
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/SesionesScreen.kt b/vela/src/main/java/com/rizoma/vela/ui/SesionesScreen.kt
new file mode 100644
index 0000000..aba0c0e
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/SesionesScreen.kt
@@ -0,0 +1,155 @@
+package com.rizoma.vela.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.vela.data.Modo
+import com.rizoma.vela.data.Sesion
+import com.rizoma.vela.ui.theme.MuyTenue
+import com.rizoma.vela.ui.theme.NeonCian
+import com.rizoma.vela.ui.theme.NeonRosa
+import com.rizoma.vela.ui.theme.NeonVerde
+import com.rizoma.vela.ui.theme.Superficie
+import com.rizoma.vela.ui.theme.SuperficieAlta
+import com.rizoma.vela.ui.theme.Tenue
+
+@Composable
+fun SesionesScreen(
+ sesiones: List<Sesion>,
+ error: String?,
+ onGrabar: () -> Unit,
+ onVigilia: () -> Unit,
+ onAbrir: (Long) -> Unit,
+ onAjustes: () -> Unit,
+) {
+ Column(Modifier.fillMaxSize().padding(20.dp)) {
+ Row(
+ Modifier.fillMaxWidth().padding(top = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ "vela", color = NeonVerde, fontSize = 30.sp,
+ fontWeight = FontWeight.Bold, letterSpacing = 4.sp,
+ )
+ Text(
+ "ajustes", color = MuyTenue, fontSize = 13.sp,
+ modifier = Modifier.clickable { onAjustes() }.padding(8.dp),
+ )
+ }
+ Text(
+ "grabadora que escucha, compacta silencios y vela de noche",
+ color = MuyTenue, fontSize = 12.sp,
+ )
+
+ if (error != null) {
+ Spacer(Modifier.height(12.dp))
+ Text(error, color = NeonRosa, fontSize = 13.sp)
+ }
+
+ Spacer(Modifier.height(24.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Accion("grabar", "captura ahora", NeonVerde, Modifier.weight(1f), onGrabar)
+ Accion("vigilia", "monitor nocturno", NeonCian, Modifier.weight(1f), onVigilia)
+ }
+
+ Spacer(Modifier.height(24.dp))
+ Text(
+ "sesiones", color = Tenue, fontSize = 13.sp, letterSpacing = 2.sp,
+ )
+ Spacer(Modifier.height(8.dp))
+ if (sesiones.isEmpty()) {
+ Box(Modifier.fillMaxSize(), Alignment.Center) {
+ Text("nada grabado todavía", color = MuyTenue, fontSize = 14.sp)
+ }
+ } else {
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(sesiones, key = { it.id }) { s -> FilaSesion(s) { onAbrir(s.id) } }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Accion(
+ titulo: String, sub: String, color: Color, modifier: Modifier, onClick: () -> Unit,
+) {
+ Column(
+ modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(Superficie)
+ .border(1.dp, color.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
+ .clickable { onClick() }
+ .padding(18.dp),
+ ) {
+ Box(Modifier.size(12.dp).clip(CircleShape).background(color))
+ Spacer(Modifier.height(12.dp))
+ Text(titulo, color = color, fontSize = 20.sp, fontWeight = FontWeight.Bold)
+ Text(sub, color = MuyTenue, fontSize = 12.sp)
+ }
+}
+
+@Composable
+private fun FilaSesion(s: Sesion, onClick: () -> Unit) {
+ Row(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
+ .background(SuperficieAlta).clickable { onClick() }
+ .padding(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val esVigilia = s.modo == Modo.VIGILIA
+ Box(
+ Modifier.size(10.dp).clip(CircleShape)
+ .background(if (esVigilia) NeonCian else NeonVerde),
+ )
+ Spacer(Modifier.size(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(fechaDe(s.inicioEpoch), color = Color.White, fontSize = 15.sp)
+ Text(
+ "${s.segmentos.size} fragmentos · ${duracionLarga(s.duracionSonoraMs)} con sonido",
+ color = MuyTenue, fontSize = 12.sp,
+ )
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ duracion(s.duracionRealMs), color = Tenue, fontSize = 14.sp,
+ fontFamily = FontFamily.Monospace,
+ )
+ // franja: fracción de la sesión con sonido
+ Spacer(Modifier.height(4.dp))
+ Box(
+ Modifier.size(width = 56.dp, height = 5.dp).clip(RoundedCornerShape(3.dp))
+ .background(Color(0x14FFFFFF)),
+ ) {
+ Box(
+ Modifier.fillMaxWidth(s.fraccionSonora.coerceIn(0f, 1f)).height(5.dp)
+ .clip(RoundedCornerShape(3.dp)).background(NeonVerde),
+ )
+ }
+ }
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt b/vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt
new file mode 100644
index 0000000..d2f3289
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/VelaApp.kt
@@ -0,0 +1,72 @@
+package com.rizoma.vela.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.vela.Pantalla
+import com.rizoma.vela.VelaViewModel
+import com.rizoma.vela.data.Modo
+import com.rizoma.vela.ui.theme.MuyTenue
+import com.rizoma.vela.ui.theme.Vacio
+
+@Composable
+fun VelaApp(vm: VelaViewModel) {
+ Box(
+ Modifier.fillMaxSize().background(Vacio)
+ .windowInsetsPadding(WindowInsets.systemBars),
+ ) {
+ when (val p = vm.pantalla) {
+ Pantalla.Sesiones -> SesionesScreen(
+ sesiones = vm.sesiones,
+ error = vm.errorMic,
+ onGrabar = { vm.grabar(Modo.NORMAL) },
+ onVigilia = { vm.grabar(Modo.VIGILIA) },
+ onAbrir = { vm.abrir(it) },
+ onAjustes = { vm.irAjustes() },
+ )
+
+ Pantalla.Grabando -> GrabandoScreen(
+ holder = vm.holder,
+ mantenerPantalla = vm.cfg.mantenerPantalla,
+ onDetener = { vm.detener() },
+ )
+
+ is Pantalla.Ver -> {
+ val sesion = remember(p.id) { vm.cargar(p.id) }
+ if (sesion == null) {
+ Box(Modifier.fillMaxSize(), Alignment.Center) {
+ Text("sesión no encontrada", color = MuyTenue, fontSize = 14.sp)
+ }
+ } else {
+ SesionScreen(
+ sesion = sesion,
+ onVolver = { vm.volver() },
+ onBorrar = { vm.borrar(it) },
+ )
+ }
+ }
+
+ Pantalla.Ajustes -> AjustesScreen(
+ cfg = vm.cfg,
+ onSensibilidad = { vm.setSensibilidad(it) },
+ onSampleRate = { vm.setSampleRate(it) },
+ onPreRoll = { vm.setPreRoll(it) },
+ onCola = { vm.setCola(it) },
+ onMantenerPantalla = { vm.setMantenerPantalla(it) },
+ onVolver = { vm.volver() },
+ )
+ }
+ }
+}
diff --git a/vela/src/main/java/com/rizoma/vela/ui/theme/Theme.kt b/vela/src/main/java/com/rizoma/vela/ui/theme/Theme.kt
new file mode 100644
index 0000000..0252feb
--- /dev/null
+++ b/vela/src/main/java/com/rizoma/vela/ui/theme/Theme.kt
@@ -0,0 +1,36 @@
+package com.rizoma.vela.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+// Paleta del rizoma: neón sobre vacío (compartida con Órbita/Trazo/Consola/Matiz/Nimbo).
+val Vacio = Color(0xFF050507)
+val Superficie = Color(0xFF0C0C12)
+val SuperficieAlta = Color(0xFF14141C)
+val NeonVerde = Color(0xFF7CFF4F)
+val NeonRosa = Color(0xFFFF2E88)
+val NeonCian = Color(0xFF00E5FF)
+val Tenue = Color(0xB3FFFFFF)
+val MuyTenue = Color(0x66FFFFFF)
+
+private val EsquemaVela = darkColorScheme(
+ primary = NeonVerde,
+ onPrimary = Vacio,
+ secondary = NeonCian,
+ onSecondary = Vacio,
+ tertiary = NeonRosa,
+ onTertiary = Vacio,
+ background = Vacio,
+ onBackground = Color.White,
+ surface = Superficie,
+ onSurface = Color.White,
+ surfaceVariant = SuperficieAlta,
+ onSurfaceVariant = Tenue,
+)
+
+@Composable
+fun VelaTheme(content: @Composable () -> Unit) {
+ MaterialTheme(colorScheme = EsquemaVela, content = content)
+}
diff --git a/vela/src/main/res/drawable/ic_launcher_fg.xml b/vela/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..2a60700
--- /dev/null
+++ b/vela/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <!-- vela encendida: llama velando en la noche -->
+ <path
+ android:fillColor="#FF2E88"
+ android:pathData="M54 30 C60 40 63 46 63 52 a9 9 0 0 1 -18 0 C45 46 48 40 54 30 Z" />
+ <path
+ android:fillColor="#00E5FF"
+ android:pathData="M54 42 C57 47 58 50 58 53 a4 4 0 0 1 -8 0 C50 50 51 47 54 42 Z" />
+ <!-- ondas de escucha alrededor -->
+ <path
+ android:pathData="M40 62 a20 20 0 0 0 28 0"
+ android:strokeColor="#7CFF4F"
+ android:strokeWidth="3"
+ android:strokeLineCap="round" />
+ <path
+ android:pathData="M33 70 a30 30 0 0 0 42 0"
+ android:strokeColor="#7CFF4F"
+ android:strokeWidth="3"
+ android:strokeLineCap="round"
+ android:strokeAlpha="0.5" />
+</vector>
diff --git a/vela/src/main/res/drawable/ic_vela.xml b/vela/src/main/res/drawable/ic_vela.xml
new file mode 100644
index 0000000..94601de
--- /dev/null
+++ b/vela/src/main/res/drawable/ic_vela.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:tint="#FFFFFF">
+ <!-- ondas de sonido, icono monocromo para la barra de estado -->
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M3 10 h2 v4 h-2 z M7 7 h2 v10 h-2 z M11 3 h2 v18 h-2 z M15 7 h2 v10 h-2 z M19 10 h2 v4 h-2 z" />
+</vector>
diff --git a/vela/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/vela/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/vela/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_bg" />
+ <foreground android:drawable="@drawable/ic_launcher_fg" />
+</adaptive-icon>
diff --git a/vela/src/main/res/values/colors.xml b/vela/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/vela/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="ic_bg">#050507</color>
+</resources>
diff --git a/vela/src/main/res/values/strings.xml b/vela/src/main/res/values/strings.xml
new file mode 100644
index 0000000..66c9f09
--- /dev/null
+++ b/vela/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">vela</string>
+</resources>
diff --git a/vela/src/main/res/values/themes.xml b/vela/src/main/res/values/themes.xml
new file mode 100644
index 0000000..d4c7499
--- /dev/null
+++ b/vela/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Theme.Vela" parent="android:Theme.Material.NoActionBar">
+ <item name="android:windowBackground">#050507</item>
+ <item name="android:statusBarColor">@android:color/transparent</item>
+ <item name="android:navigationBarColor">@android:color/transparent</item>
+ <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+ </style>
+</resources>