package net.siteed.audiostream.integration import android.content.Context import android.media.MediaCodec import android.media.MediaExtractor import android.media.MediaFormat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import net.siteed.audiostream.AACFileWriter import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import java.io.File import java.io.FileOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.PI import kotlin.math.sin /** * Instrumented tests for [AACFileWriter]. They require a real device / * emulator because MediaCodec is not available in JVM unit tests. * * The tests feed synthetic PCM 16-bit mono @ 16 kHz to the writer and check: * - the produced `.aac` file exists, is non-empty and starts with the * ADTS sync word (0xFF 0xF9), * - `MediaExtractor` can read the track with the expected MIME / sample * rate / channel count and decode roughly the expected sample count, * - a truncated file (simulating a SIGKILL mid-recording) is still * decodable up to the last complete frame. */ @RunWith(AndroidJUnit4::class) class AacFileWriterInstrumentedTest { private lateinit var context: Context private lateinit var cacheDir: File private val writtenFiles = mutableListOf() @Before fun setUp() { context = InstrumentationRegistry.getInstrumentation().targetContext cacheDir = context.cacheDir } @After fun tearDown() { writtenFiles.forEach { runCatching { it.delete() } } writtenFiles.clear() } // ---- Pure helpers ---------------------------------------------------- @Test fun adtsHeaderStartsWithSyncWord() { val header = AACFileWriter.makeADTSHeader( packetLength = 256, sampleRateIndex = 8, channelConfig = 1 ) assertEquals(7, header.size) assertEquals(0xFF.toByte(), header[0]) assertEquals(0xF9.toByte(), header[1]) } @Test fun adtsHeaderEncodesFrameLength() { val header = AACFileWriter.makeADTSHeader( packetLength = 256, sampleRateIndex = 8, channelConfig = 1 ) val frameLength = ((header[3].toInt() and 0x03) shl 11) or ((header[4].toInt() and 0xFF) shl 3) or ((header[5].toInt() and 0xE0) shr 5) assertEquals(263, frameLength) } @Test fun sampleRateIndexLookup() { assertEquals(8, AACFileWriter.adtsSampleRateIndex(16_000)) assertEquals(4, AACFileWriter.adtsSampleRateIndex(44_100)) assertEquals(3, AACFileWriter.adtsSampleRateIndex(48_000)) assertTrue(AACFileWriter.adtsSampleRateIndex(22_000) < 0) } // ---- End-to-end encode + decode ------------------------------------- @Test fun encodesSineToAdtsAndDecodesWithMediaExtractor() { val sampleRate = 16_000 val durationSec = 3 val out = File(cacheDir, "aac_writer_it_${System.currentTimeMillis()}.aac") writtenFiles += out val writer = AACFileWriter() writer.start( sampleRate = sampleRate, channels = 1, bitrate = 32_000, outFile = out ) val chunkMs = 100 val framesPerChunk = sampleRate * chunkMs / 1_000 // 1600 samples val bytesPerChunk = framesPerChunk * 2 // 16-bit val chunkCount = durationSec * 1_000 / chunkMs val sineChunk = synthesizeSineChunk(frequency = 440.0, frames = framesPerChunk, sampleRate = sampleRate) repeat(chunkCount) { writer.encode(sineChunk, bytesPerChunk) } val finalFile = writer.stop() assertNotNull(finalFile) assertEquals(out.absolutePath, finalFile!!.absolutePath) assertTrue("AAC output should be non-empty", out.length() > 0) // Sync word check val prefix = ByteArray(2) FileOutputStream(File(cacheDir, "dummy")).use {} // no-op; we reopen out for reading below out.inputStream().use { it.read(prefix) } assertEquals(0xFF.toByte(), prefix[0]) assertEquals(0xF9.toByte(), prefix[1]) // Decode track with MediaExtractor val extractor = MediaExtractor() extractor.setDataSource(out.absolutePath) assertTrue("Expected at least one audio track", extractor.trackCount >= 1) var audioIndex = -1 for (i in 0 until extractor.trackCount) { val fmt = extractor.getTrackFormat(i) if (fmt.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) { audioIndex = i break } } assertTrue("No audio track found", audioIndex >= 0) val fmt = extractor.getTrackFormat(audioIndex) assertEquals("audio/mp4a-latm", fmt.getString(MediaFormat.KEY_MIME)) assertEquals(sampleRate, fmt.getInteger(MediaFormat.KEY_SAMPLE_RATE)) assertEquals(1, fmt.getInteger(MediaFormat.KEY_CHANNEL_COUNT)) // Count packets by walking the extractor; each AAC-LC packet = 1024 samples. extractor.selectTrack(audioIndex) val buffer = ByteBuffer.allocate(8_192) var packetCount = 0 while (true) { buffer.clear() val read = extractor.readSampleData(buffer, 0) if (read < 0) break packetCount++ if (!extractor.advance()) break } extractor.release() val expectedPackets = sampleRate * durationSec / 1024 // Tolerance: encoder may emit +/- a few packets due to internal buffering assertTrue("Got $packetCount packets, expected ~$expectedPackets", packetCount >= expectedPackets - 4 && packetCount <= expectedPackets + 4) } /** * Simulates a SIGKILL mid-recording: we truncate the produced `.aac` * file to half its length, then verify MediaExtractor can still read * roughly half of the packets without throwing. */ @Test fun truncatedFileIsStillDecodable() { val sampleRate = 16_000 val durationSec = 2 val out = File(cacheDir, "aac_writer_trunc_${System.currentTimeMillis()}.aac") writtenFiles += out val writer = AACFileWriter() writer.start(sampleRate = sampleRate, channels = 1, bitrate = 32_000, outFile = out) val sineChunk = synthesizeSineChunk(frequency = 440.0, frames = sampleRate / 10, sampleRate = sampleRate) repeat(durationSec * 10) { writer.encode(sineChunk, sineChunk.size) } writer.stop() val original = out.length() assertTrue(original > 0) val truncateTo = original / 2 java.io.RandomAccessFile(out, "rw").use { it.setLength(truncateTo) } assertEquals(truncateTo, out.length()) val extractor = MediaExtractor() extractor.setDataSource(out.absolutePath) assertTrue(extractor.trackCount >= 1) extractor.selectTrack(0) val buffer = ByteBuffer.allocate(8_192) var packetCount = 0 try { while (true) { buffer.clear() val read = extractor.readSampleData(buffer, 0) if (read < 0) break packetCount++ if (!extractor.advance()) break } } finally { extractor.release() } assertTrue("Expected decodable packets in truncated file, got 0", packetCount > 0) } @Test fun startFailsOnInvalidSampleRate() { val writer = AACFileWriter() val out = File(cacheDir, "aac_writer_bad_${System.currentTimeMillis()}.aac") writtenFiles += out var thrown = false try { writer.start(sampleRate = 22_000, channels = 1, bitrate = 32_000, outFile = out) } catch (e: IllegalArgumentException) { thrown = true } assertTrue("Expected IllegalArgumentException for unsupported sample rate", thrown) assertFalse("No file should be produced for invalid start", out.exists() && out.length() > 0) } // ---- Helpers --------------------------------------------------------- private fun synthesizeSineChunk(frequency: Double, frames: Int, sampleRate: Int): ByteArray { val buf = ByteBuffer.allocate(frames * 2).order(ByteOrder.LITTLE_ENDIAN) val angleIncrement = 2.0 * PI * frequency / sampleRate for (i in 0 until frames) { val v = (sin(i * angleIncrement) * Short.MAX_VALUE).toInt().toShort() buf.putShort(v) } return buf.array() } }