gioser

sergio/humanoid

HTML 38Markdown 1TOML 10 Sutichaykuna

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>

48189204c5355e06c0ce239629f0b9437a09415f · ruwaq sergio · (3 killa ñawpaq) · tayta 84d4ac3a0 · kay commitpi sach'ata qhaway

Semántico sayk'uy

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

28 qillqakuna · +2236 −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>