diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 41a85c3..469a716 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + @@ -36,7 +38,7 @@ + android:foregroundServiceType="camera|microphone" /> \ No newline at end of file diff --git a/app/src/main/java/com/dzecevic/babymonitor/AudioRecorder.kt b/app/src/main/java/com/dzecevic/babymonitor/AudioRecorder.kt new file mode 100644 index 0000000..62420d9 --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/AudioRecorder.kt @@ -0,0 +1,100 @@ +package com.dzecevic.babymonitor + +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.media.audiofx.AcousticEchoCanceler +import android.media.audiofx.AutomaticGainControl +import android.media.audiofx.NoiseSuppressor +import android.util.Log +import java.util.concurrent.atomic.AtomicBoolean + +class AudioRecorder( + private val sampleRate: Int = 16000 +) { + + private var record: AudioRecord? = null + private val running = AtomicBoolean(false) + private var thread: Thread? = null + + fun start() { + if (running.getAndSet(true)) return + + val minBuf = AudioRecord.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + + // Target ~40ms buffer (low latency but stable) + val bufSize = (minBuf * 2).coerceAtLeast(sampleRate / 25 * 2) + + record = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, // MUCH better than MIC + sampleRate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufSize + ) + + val r = record ?: return + + if (r.state != AudioRecord.STATE_INITIALIZED) { + Log.e("AudioRecorder", "AudioRecord not initialized") + running.set(false) + return + } + + r.startRecording() + + // Disable latency-causing DSP if possible + try { + NoiseSuppressor.create(r.audioSessionId)?.enabled = false + AutomaticGainControl.create(r.audioSessionId)?.enabled = false + AcousticEchoCanceler.create(r.audioSessionId)?.enabled = false + } catch (_: Exception) {} + + thread = Thread { + val buf = ByteArray(1024) // small chunk = low latency + + while (running.get()) { + val n = r.read(buf, 0, buf.size) + + if (n > 0) { + // Optional debug level meter + /* + var max = 0 + var i = 0 + while (i + 1 < n) { + val lo = buf[i].toInt() and 0xff + val hi = buf[i + 1].toInt() + val sample = (hi shl 8) or lo + max = max.coerceAtLeast(kotlin.math.abs(sample)) + i += 2 + } + Log.d("AUDIO", "level=$max") + */ + + FrameStoreAudio.latestPcm.set(buf.copyOf(n)) + } + } + }.apply { + isDaemon = true + start() + } + } + + fun stop() { + running.set(false) + + try { thread?.join(300) } catch (_: Exception) {} + thread = null + + try { + record?.stop() + record?.release() + } catch (_: Exception) {} + + record = null + } +} diff --git a/app/src/main/java/com/dzecevic/babymonitor/AudioStore.kt b/app/src/main/java/com/dzecevic/babymonitor/AudioStore.kt new file mode 100644 index 0000000..81c3c3f --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/AudioStore.kt @@ -0,0 +1,7 @@ +package com.dzecevic.babymonitor + +import java.util.concurrent.LinkedBlockingQueue + +object AudioStore { + val audioQueue = LinkedBlockingQueue(50) +} diff --git a/app/src/main/java/com/dzecevic/babymonitor/FrameStoreAudio.kt b/app/src/main/java/com/dzecevic/babymonitor/FrameStoreAudio.kt new file mode 100644 index 0000000..4944b98 --- /dev/null +++ b/app/src/main/java/com/dzecevic/babymonitor/FrameStoreAudio.kt @@ -0,0 +1,7 @@ +package com.dzecevic.babymonitor + +import java.util.concurrent.atomic.AtomicReference + +object FrameStoreAudio { + val latestPcm = AtomicReference(null) +} diff --git a/app/src/main/java/com/dzecevic/babymonitor/MainActivity.kt b/app/src/main/java/com/dzecevic/babymonitor/MainActivity.kt index 895e7a4..db594d1 100644 --- a/app/src/main/java/com/dzecevic/babymonitor/MainActivity.kt +++ b/app/src/main/java/com/dzecevic/babymonitor/MainActivity.kt @@ -2,6 +2,7 @@ package com.dzecevic.babymonitor import android.Manifest import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.NetworkCapabilities @@ -19,88 +20,67 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import androidx.activity.compose.rememberLauncherForActivityResult import java.net.Inet4Address import java.net.NetworkInterface import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicReference -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.foundation.layout.statusBarsPadding -import android.content.Intent -import com.dzecevic.babymonitor.FrameStore -import androidx.compose.ui.Alignment - class MainActivity : ComponentActivity() { - private val latestJpeg = AtomicReference(null) - private val serverPort = 8080 - - private var cameraProvider: ProcessCameraProvider? = null private lateinit var cameraExecutor: ExecutorService + private var cameraProvider: ProcessCameraProvider? = null private var lensFacing = CameraSelector.LENS_FACING_BACK - private val frameCount = AtomicInteger(0) - private var rebindToken by mutableStateOf(0) - private val requestPermission = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - if (!granted) Toast.makeText(this, "Camera permission required", Toast.LENGTH_LONG).show() - } + private val cameraPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + cameraExecutor = Executors.newSingleThreadExecutor() - // request permission if needed if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ) { - requestPermission.launch(Manifest.permission.CAMERA) + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) } setContent { BabyMonitorScreen( onStartServer = { ip -> - val intent = Intent(this, StreamService::class.java) - ContextCompat.startForegroundService(this, intent) - - Toast.makeText(this, "Open: http://$ip:$serverPort/", Toast.LENGTH_LONG).show() + FrameStore.isStreaming = true + ContextCompat.startForegroundService( + this, + Intent(this, StreamService::class.java) + ) + Toast.makeText(this, "Open http://$ip:8080/", Toast.LENGTH_LONG).show() }, - onStopServer = { + FrameStore.isStreaming = false stopService(Intent(this, StreamService::class.java)) }, - - isServerRunning = { false }, - onSwitchCamera = { lensFacing = - if (lensFacing == CameraSelector.LENS_FACING_BACK) CameraSelector.LENS_FACING_FRONT - else CameraSelector.LENS_FACING_BACK - rebindToken++ - }, - - rebindToken = rebindToken, + if (lensFacing == CameraSelector.LENS_FACING_BACK) + CameraSelector.LENS_FACING_FRONT + else + CameraSelector.LENS_FACING_BACK - bindCamera = { previewView -> - bindCameraUseCases(previewView) + rebindToken++ }, - + bindCamera = { bindCameraUseCases(it) }, getIp = { getLocalWifiIp() }, - - getFrames = { frameCount.get() } + rebindToken = rebindToken ) } } @@ -111,15 +91,12 @@ class MainActivity : ComponentActivity() { } private fun bindCameraUseCases(previewView: PreviewView) { - if (FrameStore.isStreaming) { - return - } - if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) - != PackageManager.PERMISSION_GRANTED - ) return + + if (FrameStore.isStreaming) return val providerFuture = ProcessCameraProvider.getInstance(this) providerFuture.addListener({ + val provider = providerFuture.get() cameraProvider = provider provider.unbindAll() @@ -130,22 +107,20 @@ class MainActivity : ComponentActivity() { val analysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build() analysis.setAnalyzer(cameraExecutor) { image -> try { - val rotation = image.imageInfo.rotationDegrees - val isFront = (lensFacing == CameraSelector.LENS_FACING_FRONT) - val jpg = YuvToJpegConverter.toJpeg( - image = image, - quality = 70, - rotationDegrees = rotation, - mirrorHorizontally = isFront + image, + 70, + image.imageInfo.rotationDegrees, + lensFacing == CameraSelector.LENS_FACING_FRONT ) + FrameStore.latestJpeg.set(jpg) FrameStore.frameCount.incrementAndGet() + } catch (_: Exception) { } finally { image.close() @@ -156,33 +131,28 @@ class MainActivity : ComponentActivity() { .requireLensFacing(lensFacing) .build() - try { - provider.bindToLifecycle(this, selector, preview, analysis) - } catch (e: Exception) { - Toast.makeText(this, "Camera bind failed: ${e.message}", Toast.LENGTH_LONG).show() - } + provider.bindToLifecycle(this, selector, preview, analysis) + }, ContextCompat.getMainExecutor(this)) } private fun getLocalWifiIp(): String? { + if (!isOnWifi()) return null - // Prefer wlan interface addresses NetworkInterface.getNetworkInterfaces().toList().forEach { nif -> nif.inetAddresses.toList().forEach { addr -> if (!addr.isLoopbackAddress && addr is Inet4Address) { - if (nif.name.startsWith("wlan") || nif.name.startsWith("wifi")) { - return addr.hostAddress - } + if (nif.name.startsWith("wlan")) return addr.hostAddress } } } - // Fallback to WifiManager val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val ipInt = wm.connectionInfo.ipAddress - if (ipInt == 0) return null - return "${ipInt and 0xFF}.${(ipInt shr 8) and 0xFF}.${(ipInt shr 16) and 0xFF}.${(ipInt shr 24) and 0xFF}" + val ip = wm.connectionInfo.ipAddress + if (ip == 0) return null + + return "${ip and 0xFF}.${(ip shr 8) and 0xFF}.${(ip shr 16) and 0xFF}.${(ip shr 24) and 0xFF}" } private fun isOnWifi(): Boolean { @@ -195,21 +165,20 @@ class MainActivity : ComponentActivity() { @Composable private fun BabyMonitorScreen( - onStartServer: (ip: String) -> Unit, + onStartServer: (String) -> Unit, onStopServer: () -> Unit, - isServerRunning: () -> Boolean, onSwitchCamera: () -> Unit, bindCamera: (PreviewView) -> Unit, getIp: () -> String?, - getFrames: () -> Int, rebindToken: Int ) { + val context = LocalContext.current var ip by remember { mutableStateOf(getIp()) } - var running by remember { mutableStateOf(isServerRunning()) } + var running by remember { mutableStateOf(false) } - // UI refresh of IP when screen recomposes - LaunchedEffect(Unit) { ip = getIp() } + val audioPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { } Box(Modifier.fillMaxSize()) { @@ -219,89 +188,78 @@ private fun BabyMonitorScreen( } } - if (FrameStore.isStreaming) { - - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Streaming active", color = Color.White) - Text("Frames: ${getFrames()}", color = Color.White) - } - - } else { - + if (!FrameStore.isStreaming) { AndroidView( factory = { previewView }, modifier = Modifier.fillMaxSize() ) - } LaunchedEffect(rebindToken) { - if (!FrameStore.isStreaming) { - bindCamera(previewView) - } + if (!FrameStore.isStreaming) bindCamera(previewView) } Column( Modifier .statusBarsPadding() - .padding(top = 8.dp) - .padding(horizontal = 12.dp) + .padding(12.dp) .background(Color(0x80000000)) - .padding(10.dp) + .padding(12.dp) ) { - if (ip != null) { - Text( - text = "URL: http://$ip:8080/", - color = Color.White - ) - } else { - Text( - text = "Not connected to Wi-Fi", - color = Color.Red - ) - } + + Text( + text = ip?.let { "http://$it:8080/" } ?: "No Wi-Fi", + color = Color.White + ) Spacer(Modifier.height(8.dp)) Text( - text = if (running) "Status: STREAMING" else "Status: STOPPED", + text = if (running) "STREAMING" else "STOPPED", color = if (running) Color.Green else Color.Yellow ) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(12.dp)) Row { + Button(onClick = { onSwitchCamera() ip = getIp() - }) { Text("Switch camera") } + }) { Text("Switch") } Spacer(Modifier.width(8.dp)) Button(onClick = { + ip = getIp() - val currentIp = ip - if (currentIp == null) { - Toast.makeText(context, "Connect to Wi-Fi first", Toast.LENGTH_LONG).show() + val currentIp = ip ?: run { + Toast.makeText(context, "Connect Wi-Fi", Toast.LENGTH_LONG).show() return@Button } if (!running) { + + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) != PackageManager.PERMISSION_GRANTED + ) { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + onStartServer(currentIp) running = true + } else { onStopServer() running = false } }) { - Text(if (!running) "Start streaming" else "Stop streaming") + Text(if (!running) "Start" else "Stop") } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzecevic/babymonitor/MjpegServer.kt b/app/src/main/java/com/dzecevic/babymonitor/MjpegServer.kt index d5bdc1b..d47f160 100644 --- a/app/src/main/java/com/dzecevic/babymonitor/MjpegServer.kt +++ b/app/src/main/java/com/dzecevic/babymonitor/MjpegServer.kt @@ -17,53 +17,47 @@ class MjpegServer( """ - - + + + - + - + + + + + + + """.trimIndent() @@ -91,6 +85,33 @@ class MjpegServer( addHeader("Connection", "close") } } + "/audio" -> { + val sampleRate = 16000 + + val pipeIn = java.io.PipedInputStream() + val pipeOut = java.io.PipedOutputStream(pipeIn) + + Thread { + try { + pipeOut.write(wavHeaderPcm16Mono(sampleRate)) + pipeOut.flush() + + while (FrameStore.isStreaming) { + val pcm = FrameStoreAudio.latestPcm.get() + if (pcm != null) { + pipeOut.write(pcm) + pipeOut.flush() + } + Thread.sleep(20) + } + } catch (_: Exception) { + } finally { + try { pipeOut.close() } catch (_: Exception) {} + } + }.start() + + newChunkedResponse(Response.Status.OK, "audio/wav", pipeIn) + } else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found") } } @@ -113,4 +134,40 @@ class MjpegServer( Thread.sleep(50) return out.toByteArray() } + private fun wavHeaderPcm16Mono(sampleRate: Int): ByteArray { + val channels = 1 + val bits = 16 + val byteRate = sampleRate * channels * (bits / 8) + val blockAlign = channels * (bits / 8) + + val dataSize = 0x7fffffff // “very large” + val riffSize = 36 + dataSize + + fun le32(v: Int) = byteArrayOf( + (v and 0xff).toByte(), + ((v shr 8) and 0xff).toByte(), + ((v shr 16) and 0xff).toByte(), + ((v shr 24) and 0xff).toByte() + ) + fun le16(v: Int) = byteArrayOf( + (v and 0xff).toByte(), + ((v shr 8) and 0xff).toByte() + ) + + return byteArrayOf( + 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), + *le32(riffSize), + 'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(), + 'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(), + *le32(16), + *le16(1), + *le16(channels), + *le32(sampleRate), + *le32(byteRate), + *le16(blockAlign), + *le16(bits), + 'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), + *le32(dataSize) + ) + } } diff --git a/app/src/main/java/com/dzecevic/babymonitor/StreamService.kt b/app/src/main/java/com/dzecevic/babymonitor/StreamService.kt index 43e686c..0dfb17d 100644 --- a/app/src/main/java/com/dzecevic/babymonitor/StreamService.kt +++ b/app/src/main/java/com/dzecevic/babymonitor/StreamService.kt @@ -16,6 +16,7 @@ import androidx.lifecycle.LifecycleService import fi.iki.elonen.NanoHTTPD import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import com.dzecevic.babymonitor.AudioRecorder class StreamService : LifecycleService() { @@ -28,10 +29,19 @@ class StreamService : LifecycleService() { // start with back camera private var lensFacing = CameraSelector.LENS_FACING_BACK + private var audioThread: Thread? = null + + private val audioRecorder = AudioRecorder() + @Volatile private var audioRunning = false + + private val audioQueue = java.util.concurrent.LinkedBlockingQueue(50) + override fun onCreate() { super.onCreate() FrameStore.isStreaming = true startForeground(1, buildNotification()) + startAudioCapture() + audioRecorder.start() acquireWakeLock() cameraExecutor = Executors.newSingleThreadExecutor() @@ -50,8 +60,8 @@ class StreamService : LifecycleService() { } catch (_: Exception) { } FrameStore.isStreaming = false - server?.stop() - server = null + stopAudioCapture() + audioRecorder.stop() cameraExecutor.shutdown() releaseWakeLock() @@ -131,4 +141,43 @@ class StreamService : LifecycleService() { .setOngoing(true) .build() } + + private fun startAudioCapture() { + val sampleRate = 16000 + val channelConfig = android.media.AudioFormat.CHANNEL_IN_MONO + val audioFormat = android.media.AudioFormat.ENCODING_PCM_16BIT + + val minBuf = android.media.AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) + val recorder = android.media.AudioRecord( + android.media.MediaRecorder.AudioSource.MIC, + sampleRate, + channelConfig, + audioFormat, + minBuf * 2 + ) + + audioRunning = true + recorder.startRecording() + + audioThread = Thread { + val buf = ByteArray(2048) + while (audioRunning) { + val read = recorder.read(buf, 0, buf.size) + if (read > 0) { + // copy exact bytes read + val chunk = buf.copyOf(read) + audioQueue.offer(chunk) // drop if full is ok for live stream + } + } + try { recorder.stop() } catch (_: Exception) {} + recorder.release() + }.apply { start() } + } + + private fun stopAudioCapture() { + audioRunning = false + audioThread?.join(500) + audioThread = null + audioQueue.clear() + } }