feat: integrate audio engine pipeline with MediaCodec and AudioTrack

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 02:18:36 -04:00
parent 7814d682f6
commit cfb04d3200
4 changed files with 243 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
package xyz.cottongin.radio247.audio
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.media.MediaCodec
import android.media.MediaFormat
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import java.util.concurrent.atomic.AtomicLong
class AudioEngine(
private val url: String,
private val bufferMs: Int = 0
) {
private val _events = MutableSharedFlow<AudioEngineEvent>(extraBufferCapacity = 64)
val events: SharedFlow<AudioEngineEvent> = _events
private var thread: Thread? = null
@Volatile
private var running = false
private val _estimatedLatencyMs = AtomicLong(0)
val estimatedLatencyMs: Long get() = _estimatedLatencyMs.get()
fun start() {
running = true
thread = Thread({
try {
runPipeline()
} catch (e: Exception) {
if (running) {
val error = when (e) {
is ConnectionFailed ->
EngineError.ConnectionFailed(e)
else -> EngineError.DecoderError(e)
}
_events.tryEmit(AudioEngineEvent.Error(error))
}
} finally {
_events.tryEmit(AudioEngineEvent.Stopped)
}
}, "AudioEngine").apply { start() }
}
fun stop() {
running = false
thread?.interrupt()
thread = null
}
private fun runPipeline() {
val connection = StreamConnection(url)
connection.open()
val sampleRate = 44100
val channelConfig = AudioFormat.CHANNEL_OUT_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
val audioTrack = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(channelConfig)
.setEncoding(encoding)
.build()
)
.setBufferSizeInBytes(minBuf)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
val codec = MediaCodec.createDecoderByType("audio/mpeg")
val format = MediaFormat.createAudioFormat("audio/mpeg", sampleRate, 2)
codec.configure(format, null, null, 0)
codec.start()
audioTrack.play()
_events.tryEmit(AudioEngineEvent.Started)
try {
val bufferFrames = if (bufferMs > 0) (bufferMs / 26).coerceAtLeast(1) else 0
val ringBuffer = RingBuffer(bufferFrames) { mp3Frame ->
decodeToPcm(codec, mp3Frame, audioTrack)
}
val frameSync = Mp3FrameSync { mp3Frame ->
ringBuffer.write(mp3Frame)
}
val icyParser = IcyParser(
input = connection.inputStream!!,
metaint = connection.metaint,
onAudioData = { buf, off, len -> frameSync.feed(buf, off, len) },
onMetadata = { _events.tryEmit(AudioEngineEvent.MetadataChanged(it)) }
)
icyParser.readAll()
// Stream ended normally
ringBuffer.flush()
frameSync.flush()
_events.tryEmit(AudioEngineEvent.Error(EngineError.StreamEnded))
} finally {
codec.stop()
codec.release()
audioTrack.stop()
audioTrack.release()
connection.close()
}
}
private fun decodeToPcm(codec: MediaCodec, mp3Frame: ByteArray, audioTrack: AudioTrack) {
val inIdx = codec.dequeueInputBuffer(1000)
if (inIdx >= 0) {
val inBuf = codec.getInputBuffer(inIdx)!!
inBuf.clear()
inBuf.put(mp3Frame)
codec.queueInputBuffer(inIdx, 0, mp3Frame.size, 0, 0)
}
val bufferInfo = MediaCodec.BufferInfo()
var outIdx = codec.dequeueOutputBuffer(bufferInfo, 1000)
while (outIdx >= 0) {
val outBuf = codec.getOutputBuffer(outIdx)!!
outBuf.position(bufferInfo.offset)
outBuf.limit(bufferInfo.offset + bufferInfo.size)
val pcmData = ByteArray(bufferInfo.size)
outBuf.get(pcmData)
codec.releaseOutputBuffer(outIdx, false)
audioTrack.write(pcmData, 0, pcmData.size)
outIdx = codec.dequeueOutputBuffer(bufferInfo, 0)
}
}
}

View File

@@ -0,0 +1,15 @@
package xyz.cottongin.radio247.audio
sealed interface AudioEngineEvent {
data class MetadataChanged(val metadata: IcyMetadata) : AudioEngineEvent
data class Error(val cause: EngineError) : AudioEngineEvent
data object Started : AudioEngineEvent
data object Stopped : AudioEngineEvent
}
sealed interface EngineError {
data class ConnectionFailed(val cause: Throwable) : EngineError
data object StreamEnded : EngineError
data class DecoderError(val cause: Throwable) : EngineError
data class AudioOutputError(val cause: Throwable) : EngineError
}

View File

@@ -0,0 +1,25 @@
package xyz.cottongin.radio247.audio
class RingBuffer(
private val capacityFrames: Int,
private val onFrame: (ByteArray) -> Unit
) {
private val buffer = ArrayDeque<ByteArray>()
fun write(frame: ByteArray) {
if (capacityFrames == 0) {
onFrame(frame)
return
}
if (buffer.size >= capacityFrames) {
onFrame(buffer.removeFirst())
}
buffer.addLast(frame)
}
fun flush() {
while (buffer.isNotEmpty()) {
onFrame(buffer.removeFirst())
}
}
}