AudioPlayer.kt
src/jvmMain/kotlin/afield/graphics/texture/video/AudioPlayer.kt · 374 lines
Streams PCM audio through OpenAL by queuing a fixed ring of buffers, and reports the playback position that AudioClock uses to keep video in sync.
package afield.graphics.texture.video
import org.lwjgl.openal.AL
import org.lwjgl.openal.AL10.*
import org.lwjgl.openal.AL11.AL_SAMPLE_OFFSET
import org.lwjgl.openal.ALC
import org.lwjgl.openal.ALC10.*
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
class AudioPlayer
AudioPlayer manages OpenAL audio playback for synchronized video/audio. Supports streaming audio via buffer queuing for low-latency playback.
class AudioPlayer(
private val sampleRate: Int = 44100,
private val channels: Int = 2
) {
private var device: Long = 0
private var context: Long = 0
private var source: Int = 0
private var buffers: IntArray = IntArray(NUM_BUFFERS)
private var isInitialized = false
private var isPlaying = false
Queue for tracking buffer states
private val freeBuffers = ConcurrentLinkedQueue<Int>()
private val queuedBuffers = ConcurrentLinkedQueue<Int>()
Track total samples processed for accurate timing
private var totalSamplesProcessed: Long = 0
private var lastSampleOffset: Int = 0
Format constants
private val format = when (channels) {
1 -> AL_FORMAT_MONO16
2 -> AL_FORMAT_STEREO16
else -> throw IllegalArgumentException("Unsupported channel count: $channels")
}
var volume: Float
Gain on the OpenAL source: 0 for silence, 1 for the decoded level, and values outside that range clamped into it. Setting it before initialize is not lost, since initialize applies whatever it holds to the source it creates.
var volume: Float = 1.0f
set(value) {
field = value.coerceIn(0.0f, 1.0f)
if (isInitialized) {
alSourcef(source, AL_GAIN, field)
}
}
fun initialize(): Boolean
Initialize OpenAL context and resources.
body — 79 lines, from line 63
fun initialize(): Boolean {
if (isInitialized) return true
try {
// Open default audio device
device = alcOpenDevice(null as ByteBuffer?)
if (device == 0L) {
println("Error: Failed to open OpenAL device")
return false
}
// Create context
context = alcCreateContext(device, null as IntArray?)
if (context == 0L) {
println("Error: Failed to create OpenAL context")
alcCloseDevice(device)
device = 0
return false
}
// Make context current
if (!alcMakeContextCurrent(context)) {
println("Error: Failed to make OpenAL context current")
alcDestroyContext(context)
alcCloseDevice(device)
context = 0
device = 0
return false
}
// Create capabilities for the context
val alcCapabilities = ALC.createCapabilities(device)
AL.createCapabilities(alcCapabilities)
// Check for errors
val error = alGetError()
if (error != AL_NO_ERROR) {
println("Error: OpenAL initialization error: $error")
cleanup()
return false
}
// Generate audio source
source = alGenSources()
if (alGetError() != AL_NO_ERROR) {
println("Error: Failed to generate OpenAL source")
cleanup()
return false
}
// Generate buffers
alGenBuffers(buffers)
if (alGetError() != AL_NO_ERROR) {
println("Error: Failed to generate OpenAL buffers")
cleanup()
return false
}
// Initialize all buffers as free
buffers.forEach { freeBuffers.offer(it) }
// Set source properties
alSourcef(source, AL_PITCH, 1.0f)
alSourcef(source, AL_GAIN, volume)
alSource3f(source, AL_POSITION, 0.0f, 0.0f, 0.0f)
alSource3f(source, AL_VELOCITY, 0.0f, 0.0f, 0.0f)
alSourcei(source, AL_LOOPING, AL_FALSE)
println("OpenAL initialized: ${sampleRate}Hz, $channels channels")
isInitialized = true
return true
} catch (e: Exception) {
println("Error initializing OpenAL: ${e.message}")
e.printStackTrace()
cleanup()
return false
}
}
fun queueBuffer(audioData: ByteBuffer, numSamples: Int = audioData.remaining() / (channels * 2)): Boolean
Queue an audio buffer for playback. @param audioData PCM audio data (16-bit signed, interleaved) @param numSamples Number of samples in the buffer (for timing tracking) @return true if buffer was successfully queued
fun queueBuffer(audioData: ByteBuffer, numSamples: Int = audioData.remaining() / (channels * 2)): Boolean {
if (!isInitialized) return false
// Get a free buffer (non-blocking check)
val buffer = freeBuffers.poll()
if (buffer == null) {
// No free buffers available - caller should process buffers first
return false
}
// Upload data to buffer
alBufferData(buffer, format, audioData, sampleRate)
if (alGetError() != AL_NO_ERROR) {
println("Error uploading audio data to buffer")
freeBuffers.offer(buffer) // Return buffer to free pool
return false
}
// Queue buffer on source
alSourceQueueBuffers(source, buffer)
if (alGetError() != AL_NO_ERROR) {
println("Error queuing audio buffer")
freeBuffers.offer(buffer)
return false
}
queuedBuffers.offer(buffer)
return true
}
fun queueAudioFrame(audioFrame: AudioFrame): Boolean
Queue an AudioFrame for playback (convenience method). @param audioFrame Decoded audio frame from AudioDecoder @return true if buffer was successfully queued
fun queueAudioFrame(audioFrame: AudioFrame): Boolean {
return queueBuffer(audioFrame.data, audioFrame.samples)
}
fun processBuffers()
Process finished buffers and return them to the free pool. Also updates timing information.
fun processBuffers() {
if (!isInitialized) return
val processed = alGetSourcei(source, AL_BUFFERS_PROCESSED)
for (i in 0 until processed) {
val buffer = alSourceUnqueueBuffers(source)
if (alGetError() == AL_NO_ERROR) {
// Get buffer size to update total samples
val bufferSize = alGetBufferi(buffer, AL_SIZE)
val samplesInBuffer = bufferSize / (channels * 2) // 2 bytes per sample
totalSamplesProcessed += samplesInBuffer
queuedBuffers.remove(buffer)
freeBuffers.offer(buffer)
}
}
}
fun play()
Start audio playback.
fun play() {
if (!isInitialized || isPlaying) return
alSourcePlay(source)
if (alGetError() != AL_NO_ERROR) {
println("Error starting audio playback")
return
}
isPlaying = true
}
fun pause()
Pause audio playback.
fun pause() {
if (!isInitialized || !isPlaying) return
alSourcePause(source)
if (alGetError() != AL_NO_ERROR) {
println("Error pausing audio playback")
return
}
isPlaying = false
}
fun stop()
Stop audio playback and clear all queued buffers.
fun stop() {
if (!isInitialized) return
alSourceStop(source)
if (alGetError() != AL_NO_ERROR) {
println("Error stopping audio playback")
}
// Unqueue all buffers
val queued = alGetSourcei(source, AL_BUFFERS_QUEUED)
for (i in 0 until queued) {
val buffer = alSourceUnqueueBuffers(source)
if (alGetError() == AL_NO_ERROR) {
queuedBuffers.remove(buffer)
freeBuffers.offer(buffer)
}
}
// Reset timing
totalSamplesProcessed = 0
lastSampleOffset = 0
isPlaying = false
}
fun getCurrentTime(): Double
Get current playback time in seconds. Tracks total samples played across all buffers for accurate timing. @return Current playback time, or 0.0 if not playing
fun getCurrentTime(): Double {
if (!isInitialized) return 0.0
// Get current sample offset within the current buffer
val currentOffset = alGetSourcei(source, AL_SAMPLE_OFFSET)
// Handle offset wrap-around when buffers are processed
if (currentOffset < lastSampleOffset) {
// Offset wrapped, a buffer was completed
// (processBuffers() should have already updated totalSamplesProcessed)
}
lastSampleOffset = currentOffset
// Total time = (all processed samples + current offset) / sample rate
return (totalSamplesProcessed + currentOffset).toDouble() / sampleRate
}
fun getQueuedBufferCount(): Int
Get number of buffers currently queued.
fun getQueuedBufferCount(): Int {
if (!isInitialized) return 0
return alGetSourcei(source, AL_BUFFERS_QUEUED)
}
fun getProcessedBufferCount(): Int
Get number of buffers processed (ready to be unqueued).
fun getProcessedBufferCount(): Int {
if (!isInitialized) return 0
return alGetSourcei(source, AL_BUFFERS_PROCESSED)
}
fun isCurrentlyPlaying(): Boolean
Check if audio is currently playing.
fun isCurrentlyPlaying(): Boolean {
if (!isInitialized) return false
val state = alGetSourcei(source, AL_SOURCE_STATE)
return state == AL_PLAYING
}
fun getFreeBufferCount(): Int = freeBuffers.size
Get number of free buffers available for queuing.
fun cleanup()
Clean up OpenAL resources.
body — 45 lines, from line 324
fun cleanup() {
if (!isInitialized) return
// Stop playback
if (isPlaying) {
stop()
}
// Delete buffers
if (buffers.isNotEmpty()) {
alDeleteBuffers(buffers)
buffers = IntArray(0)
}
// Delete source
if (source != 0) {
alDeleteSources(source)
source = 0
}
// Destroy context
if (context != 0L) {
alcMakeContextCurrent(0L)
alcDestroyContext(context)
context = 0L
}
// Close device
if (device != 0L) {
alcCloseDevice(device)
device = 0L
}
freeBuffers.clear()
queuedBuffers.clear()
// Reset timing
totalSamplesProcessed = 0
lastSampleOffset = 0
isInitialized = false
isPlaying = false
println("OpenAL cleaned up")
}
companion object
companion object {
private const val NUM_BUFFERS = 4 // Number of buffers for streaming
}
}