feat: integrate audio engine pipeline with MediaCodec and AudioTrack
Made-with: Cursor
This commit is contained in:
142
app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt
Normal file
142
app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
25
app/src/main/java/xyz/cottongin/radio247/audio/RingBuffer.kt
Normal file
25
app/src/main/java/xyz/cottongin/radio247/audio/RingBuffer.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user