FieldAudioEngine.kt
src/jvmMain/kotlin/afield/audio/synth/FieldAudioEngine.kt · 846 lines
@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth
import afield.data.query.graph.LiveGraphEdge
import afield.data.query.graph.LiveGraphInspectable
import afield.app.diagnostics.logging.lprintln
import org.lwjgl.openal.AL
import org.lwjgl.openal.AL10.*
import org.lwjgl.openal.ALC
import org.lwjgl.openal.ALC10.*
import org.lwjgl.openal.ALC11.*
import org.lwjgl.system.MemoryStack
import org.lwjgl.system.MemoryUtil
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.ShortBuffer
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.collections.ArrayDeque
import kotlin.concurrent.thread
import kotlin.math.roundToInt
The file that opens an audio device: the OpenAL output stream Field plays through, the optional
capture stream audioInput() reads from, and the mixer that sums every registered patch into it.
AudioPatchSpec is one thing to hear, either a mono Signal with a gain and a pan or a
StereoRenderable with a gain. A box builds one by returning audio { … } from synth.kt rather
than by calling the constructors here. FieldAudioEngine is the device and the list of patches on
it; shared(), reached from a box as audioEngine(), is the single instance a process gets, and
its sample rate and buffer size are whatever the first caller in the session asked for. register
takes a spec and hands back an AudioPatchHandle, and closing that handle is the only way a patch
leaves the mix.
Rendering is pulled by the device rather than driven by a clock. One daemon thread keeps a queue of
OpenAL buffers filled, bufferSize frames each, and renders the next block only when the source
reports an earlier buffer as processed, so the mix advances as fast as the sound card consumes it
and no faster. A SynthContext is advanced once per block and handed to every patch. Patches
accumulate rather than replace: each renderer adds into the same pair of buffers, a mono one
through an equal-power pan law that puts a centred signal at 0.707 in both channels rather than at
half in each. The sum is clamped to [-1, 1] and written out as 16-bit stereo, and a sample that is
not finite becomes silence and is counted in nonFiniteSamplesSanitized, which reaches the
warning,audio log channel at most once every two seconds.
Three properties of that loop account for most silence. outputActive means the device opened, not
that audio is flowing: it is set when the OpenAL source and its buffers exist and cleared only when
the thread exits, while rendering depends on that source continuing to report processed buffers. A
machine too loaded to service its own audio daemon therefore renders nothing with every flag still
reading true, and a patch that counts its own generateStereo calls is what separates that case
from a wiring mistake. If the device cannot be opened at all, the thread prints why and returns
without retrying, so start() does nothing from then on and patches register into a mix nobody
renders; destroyShared() forces a fresh attempt. And input is the newest block the capture device
has rather than a continuous stream: at most bufferSize frames are taken per block, and a block
with nothing ready reads as silence.
enableLinkOutput sends the same mix on to Ableton Link through LinkAudioInterface, and can mute
the local device while it does by zeroing the OpenAL source gain; the mix is rendered either way. A
Link interface opened anywhere else in the process is picked up per block without this file being
told about it, so a patch can reach Link without the call.
class AudioPatchSpec internal constructor
One thing to hear: a signal graph, the gain and pan it plays at, and how many blocks to render it for before it joins the mix.
A spec holds no device and makes no sound by itself. Boxes build one with audio { … } from
synth.kt; the two from overloads are the direct form, one for a mono Signal and one for a
StereoRenderable. Registering the same spec twice puts it in the mix twice.
class AudioPatchSpec internal constructor(
internal var renderer: Renderer,
/**
* Blocks to render and throw away on the registering thread before the patch joins the mix.
*
* Zero by default. Enough of them allocates the graph's internal buffers and gets its hot path
* compiled before the audio thread reaches it, which is the difference between a first block that
* is late and one that is not.
*/
val warmUpCycles: Int = 0
) : LiveGraphInspectable {
internal val visualTrace = StereoTrace(capacity = 4096, stride = 1)
internal var visualSampleRate: Int = 48_000
internal var inlineSummaryTarget: InlineAudioSummaryTarget? = null
internal interface Renderer
fun render(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext)
companion object
companion object {
fun from
A mono signal scaled by gain and placed by pan, both of which are Params and so can
themselves be signals.
pan runs -1 to 1 through an equal-power law, so the centre is 0.707 in each channel and
the two ends are one channel only.
fun from(
signal: Signal,
gain: Param = Param(1f),
pan: Param = Param(0f),
warmUpCycles: Int = 0
): AudioPatchSpec {
val spec = AudioPatchSpec(renderer = PlaceholderRenderer, warmUpCycles = warmUpCycles)
spec.renderer = MonoRenderer(signal, gain, pan, spec.visualTrace)
return spec
}
fun from
A stereo signal scaled by gain, with no pan: a two-channel source is already placed.
The source is given StereoNode's per-block cache if it does not already have one, so a stateful graph reached twice in one block still advances once.
fun from(
signal: StereoRenderable,
gain: Param = Param(1f),
warmUpCycles: Int = 0
): AudioPatchSpec {
val spec = AudioPatchSpec(renderer = PlaceholderRenderer, warmUpCycles = warmUpCycles)
spec.renderer = StereoRenderer(signal.hold(), gain, spec.visualTrace)
return spec
}
}
internal fun clearVisualizers()
internal fun clearVisualizers() {
visualTrace.clear()
}
private object PlaceholderRenderer : Renderer {
override fun render(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) = Unit
}
override fun liveGraphChildren(): Iterable<LiveGraphEdge>
override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
LiveGraphEdge("renderer") { renderer },
LiveGraphEdge("warmUpCycles") { warmUpCycles }
)
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"renderer" to renderer,
"warmUpCycles" to warmUpCycles
)
}
internal data class InlineAudioSummaryTarget
internal data class InlineAudioSummaryTarget(
val line: Int,
val property: String = "code",
val annotation: String? = null
)
class AudioPatchHandle internal constructor
A registration rather than a patch: closing it takes the spec out of the mix and does nothing else.
The AudioPatchSpec itself survives and can be registered again. When a box returns a patch, the
return-value handler in AudioReturnValue.kt holds the handle for as long as that run of the box
lasts and closes it in a finally, which is what stops a re-run box from playing its old sound
underneath its new one.
class AudioPatchHandle internal constructor(
private val engine: FieldAudioEngine,
private val id: String,
private val ownership: afield.runtime.lifecycle.Ownership? = null
) : AutoCloseable {
override fun close()
override fun close() {
// The ownership's release is the unregister, so closing either one is closing both.
if (ownership != null) ownership.close() else engine.unregister(id)
}
override fun toString(): String = "AudioPatchHandle(id=$id)"
source — 79 lines, from line 175
private class MonoRenderer(
private val source: Signal,
private val gain: Param,
private val pan: Param,
private val trace: StereoTrace
) : AudioPatchSpec.Renderer, LiveGraphInspectable {
private var srcBuf = FloatArray(0)
private var gainBuf = FloatArray(0)
private var panBuf = FloatArray(0)
private var patchL = FloatArray(0)
private var patchR = FloatArray(0)
private val piOver4 = (Math.PI / 4.0).toFloat()
override fun render(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
if (srcBuf.size < numFrames) {
srcBuf = FloatArray(numFrames)
gainBuf = FloatArray(numFrames)
panBuf = FloatArray(numFrames)
patchL = FloatArray(numFrames)
patchR = FloatArray(numFrames)
}
source.render(srcBuf, numFrames, ctx)
gain.render(gainBuf, numFrames, ctx)
pan.render(panBuf, numFrames, ctx)
for (i in 0 until numFrames) {
val sample = srcBuf[i] * gainBuf[i]
val p = panBuf[i].coerceIn(-1f, 1f)
val angle = p * piOver4
val left = sample * kotlin.math.cos(angle + piOver4)
val right = sample * kotlin.math.sin(angle + piOver4)
patchL[i] = left
patchR[i] = right
outL[i] += left
outR[i] += right
}
trace.recordStereo(patchL, patchR, numFrames)
}
override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
LiveGraphEdge("source") { source },
LiveGraphEdge("gain") { gain },
LiveGraphEdge("pan") { pan }
)
}
private class StereoRenderer(
private val source: StereoRenderable,
private val gain: Param,
private val trace: StereoTrace
) : AudioPatchSpec.Renderer, LiveGraphInspectable {
private var leftBuf = FloatArray(0)
private var rightBuf = FloatArray(0)
private var gainBuf = FloatArray(0)
override fun render(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
if (leftBuf.size < numFrames) {
leftBuf = FloatArray(numFrames)
rightBuf = FloatArray(numFrames)
gainBuf = FloatArray(numFrames)
}
source.render(leftBuf, rightBuf, numFrames, ctx)
gain.render(gainBuf, numFrames, ctx)
for (i in 0 until numFrames) {
val g = gainBuf[i]
leftBuf[i] *= g
rightBuf[i] *= g
outL[i] += leftBuf[i]
outR[i] += rightBuf[i]
}
trace.recordStereo(leftBuf, rightBuf, numFrames)
}
override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
LiveGraphEdge("source") { source },
LiveGraphEdge("gain") { gain }
)
}
class FieldAudioEngine private constructor
The audio device and the patches playing through it.
Reach it with shared(), or with audioEngine() from a box; the constructor is private and one
process gets one engine. sampleRate and bufferSize are fixed when it is created and every patch
is rendered against the same SynthContext, advanced once per block on the engine's own daemon
thread. Nothing resamples, so a source that generates at a rate of its own has to be built at the
engine's sampleRate, which is why parkDough reads that off the engine before it builds a
DoughEngine.
class FieldAudioEngine private constructor(
val sampleRate: Int = 48_000,
val bufferSize: Int = 512,
private val queuedBufferCount: Int = 12,
private val inputChannels: Int = 2
) : AutoCloseable {
companion object
companion object {
private const val NON_FINITE_LOG_INTERVAL_NS = 2_000_000_000L
@Volatile
private var sharedInstance: FieldAudioEngine? = null
fun shared
The process-wide engine, created and started if it is not already running.
The arguments are used only when it has to be created, so the first caller in a session
fixes the sample rate, the buffer size and the queue depth, and every later call gets that
engine whatever it asks for. inputChannels is clamped to 1 or 2.
@Synchronized
fun shared(
sampleRate: Int = 48_000,
bufferSize: Int = 512,
queuedBufferCount: Int = 12,
inputChannels: Int = 2
): FieldAudioEngine {
val engine = sharedInstance ?: FieldAudioEngine(
sampleRate = sampleRate,
bufferSize = bufferSize,
queuedBufferCount = queuedBufferCount,
inputChannels = inputChannels.coerceIn(1, 2)
).also {
sharedInstance = it
}
engine.start()
return engine
}
fun destroyShared()
Close the process-wide engine and forget it, so the next shared() opens the device again.
This is the way back from a failed device open, which does not retry on its own, and the only way to change the sample rate or buffer size a session is running at.
@Synchronized
fun destroyShared() {
sharedInstance?.close()
sharedInstance = null
}
}
private val started = AtomicBoolean(false)
private val shouldStop = AtomicBoolean(false)
private val patches = ConcurrentHashMap<String, AudioPatchSpec>()
private val startLatch = CountDownLatch(1)
private val synthContext = SynthContext(sampleRate.toFloat(), bufferSize)
private val mixL = FloatArray(bufferSize)
private val mixR = FloatArray(bufferSize)
private val pcmBuffer: ByteBuffer =
ByteBuffer.allocateDirect(bufferSize * 2 * 2).order(ByteOrder.nativeOrder())
private val linkPcm = ShortArray(bufferSize * 2)
private val captureState = CaptureState(bufferSize)
private val monoInputSignal = CaptureSignal(captureState, -1)
private val leftInputSignal = CaptureSignal(captureState, 0)
private val rightInputSignal = CaptureSignal(captureState, 1)
private var audioThread: Thread? = null
private var device: Long = 0L
private var context: Long = 0L
private var source: Int = 0
private var buffers: IntArray = IntArray(0)
private val freeBuffers = ArrayDeque<Int>()
private var captureDevice: Long = 0L
private var captureScratch: ShortBuffer? = null
@Volatile
private var linkOutput: LinkAudioInterface? = null
var monitorLocalOutput: Boolean
Whether the local OpenAL source is audible.
Only enableLinkOutput changes it. False mutes the device by setting its gain to zero and does
not stop the mix being rendered.
@Volatile
var monitorLocalOutput: Boolean = true
private set
var inputActive: Boolean
Whether a capture device is open.
False leaves monoInput() and stereoInput() producing silence rather than failing, so a
graph built on input still renders on a machine with no microphone.
@Volatile
var inputActive: Boolean = false
private set
var outputActive: Boolean
Whether the output device opened, which is not the same as audio flowing.
It is set once the OpenAL source and its buffers exist and cleared only when the audio thread exits, so it stays true while a source that has stopped reporting processed buffers renders nothing at all.
@Volatile
var outputActive: Boolean = false
private set
var nonFiniteSamplesSanitized: Long
How many NaN or infinite samples have been replaced with silence since the engine started, counted once per sample and per channel.
A graph that has gone non-finite goes quiet rather than loud, so this counter is usually how it
is noticed. It is also logged to the warning,audio channel, at most once every two seconds.
@Volatile
var nonFiniteSamplesSanitized: Long = 0L
private set
private var nonFiniteSamplesSinceLog: Long = 0L
private var nextNonFiniteLogAtNs: Long = System.nanoTime() + NON_FINITE_LOG_INTERVAL_NS
fun monoInput(): Signal
The capture device's newest block as a mono Signal, starting the engine if it is not running.
A stereo capture device is averaged to one channel. Whenever capture is unavailable, or has nothing ready at the top of a block, the signal reads zero.
fun monoInput(): Signal {
start()
return monoInputSignal
}
fun stereoInput(): StereoSignal
The capture device's newest block as a StereoSignal, starting the engine if it is not running.
A mono capture device is copied to both channels, so the pair is always two full channels.
fun stereoInput(): StereoSignal {
start()
return StereoSignal(leftInputSignal, rightInputSignal)
}
fun enableLinkOutput
Publish the mix to Ableton Link as 16-bit stereo, and return this engine so the call chains.
With monitorLocal false the local device is muted while Link carries the sound, which stops it
leaving the machine twice; the mix is rendered either way. maxFrames is raised to bufferSize
if it is smaller, since that is the size of the blocks Link will be handed.
fun enableLinkOutput(
link: LinkAudioInterface = LinkAudioInterface.shared(),
name: String = "Field4",
maxFrames: Int = bufferSize,
monitorLocal: Boolean = true
): FieldAudioEngine {
link.enableAudioOutput(name = name, maxFrames = maxFrames.coerceAtLeast(bufferSize), channels = 2)
linkOutput = link
monitorLocalOutput = monitorLocal
applyLocalOutputGain()
start()
return this
}
fun disableLinkOutput(): FieldAudioEngine
Drop the engine's own reference to Link and unmute the local device.
This clears a reference rather than a connection: the block loop falls back to whatever
LinkAudioInterface is still current in the process, so a shared interface opened elsewhere
keeps receiving the mix. LinkAudioInterface.destroyShared() is what ends that.
fun disableLinkOutput(): FieldAudioEngine {
linkOutput = null
monitorLocalOutput = true
applyLocalOutputGain()
return this
}
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"sampleRate" to sampleRate,
"bufferSize" to bufferSize,
"queuedBufferCount" to queuedBufferCount,
"inputChannels" to inputChannels,
"patchCount" to patches.size,
"inputActive" to inputActive,
"outputActive" to outputActive,
"monitorLocalOutput" to monitorLocalOutput,
"linkOutput" to (linkOutput?.snapshot())
)
fun register(spec: AudioPatchSpec): AudioPatchHandle
Put spec in the mix and return the handle that takes it out again.
Starts the engine if it is not running, then renders the spec's warmUpCycles blocks on the
calling thread and throws the audio away: that allocates the graph's internal buffers and gives
HotSpot passes over the hot path before the audio thread ever reaches it. The default is zero
cycles. The next block the device asks for is the first one the patch is heard in.
fun register(spec: AudioPatchSpec): AudioPatchHandle {
start()
warmUp(spec)
spec.clearVisualizers()
spec.visualSampleRate = sampleRate
val id = UUID.randomUUID().toString()
patches[id] = spec
// Recorded against the box that is executing, so `lifecycle-census` can say whose patch is
// still in the mix; nothing else knows once registration returns.
val ownership = afield.runtime.lifecycle.LifecycleLedger.claim(
afield.runtime.lifecycle.LifecycleKinds.AUDIO_PATCH,
id,
live = { patches.containsKey(id) },
release = { unregister(id) },
)
return AudioPatchHandle(this, id, ownership)
}
internal fun unregister(id: String)
internal fun unregister(id: String) {
patches.remove(id)
}
fun start()
Open the devices and begin rendering, or return at once if that has already been attempted.
Blocks for up to five seconds while the audio thread tries to open its output and capture
devices, so outputActive and inputActive can be read immediately afterwards. Every entry
point that needs the engine running calls this, so a box rarely does. A second call after a
failed open does not try again: destroyShared() is what does.
fun start() {
if (started.compareAndSet(false, true)) {
shouldStop.set(false)
audioThread = thread(start = true, isDaemon = true, name = "FieldAudioEngine") {
try {
runLoop()
} catch (t: Throwable) {
lprintln(
"error,audio",
"FieldAudioEngine crashed: ${t.message ?: t.javaClass.simpleName} " +
"(sampleRate=$sampleRate, bufferSize=$bufferSize, queuedBufferCount=$queuedBufferCount, patchCount=${patches.size})"
)
lprintln("error,audio", t.stackTraceToString())
throw t
} finally {
outputActive = false
inputActive = false
startLatch.countDown()
}
}
}
startLatch.await(5, TimeUnit.SECONDS)
}
override fun close()
Ask the audio thread to stop and wait up to two seconds for it, releasing the OpenAL device, source, buffers and capture stream on the way out.
The engine does not come back afterwards: start() sees that it has already started and does
nothing. Use destroyShared() to close the process-wide engine and get a new one.
body — 317 lines, from line 529
override fun close() {
shouldStop.set(true)
audioThread?.join(2_000)
}
private fun warmUp(spec: AudioPatchSpec) {
val cycles = spec.warmUpCycles
if (cycles <= 0) return
val tempCtx = SynthContext(sampleRate.toFloat(), bufferSize)
val outL = FloatArray(bufferSize)
val outR = FloatArray(bufferSize)
repeat(cycles) {
tempCtx.advanceCycle()
Buf.clear(outL, 0, bufferSize)
Buf.clear(outR, 0, bufferSize)
spec.renderer.render(outL, outR, bufferSize, tempCtx)
}
}
private fun runLoop() {
try {
initializeOutput()
initializeCapture()
startLatch.countDown()
if (!outputActive) return
repeat(queuedBufferCount) {
queueNextBuffer()
}
ensurePlaying()
val sleepMillis = ((bufferSize * 1_000.0) / sampleRate / 3.0).roundToInt().coerceAtLeast(1)
while (!shouldStop.get()) {
reclaimProcessedBuffers()
while (freeBuffers.isNotEmpty()) {
queueNextBuffer()
}
ensurePlaying()
Thread.sleep(sleepMillis.toLong())
}
} finally {
runCatching { shutdownCapture() }.onFailure {
lprintln("error,audio", "FieldAudioEngine capture shutdown failed: ${it.message ?: it.javaClass.simpleName}")
lprintln("error,audio", it.stackTraceToString())
}
runCatching { shutdownOutput() }.onFailure {
lprintln("error,audio", "FieldAudioEngine output shutdown failed: ${it.message ?: it.javaClass.simpleName}")
lprintln("error,audio", it.stackTraceToString())
}
}
}
private fun initializeOutput() {
try {
device = alcOpenDevice(null as ByteBuffer?)
if (device == 0L) {
println("FieldAudioEngine: failed to open OpenAL output device")
return
}
context = alcCreateContext(device, null as IntArray?)
if (context == 0L || !alcMakeContextCurrent(context)) {
println("FieldAudioEngine: failed to create OpenAL context")
return
}
val alcCapabilities = ALC.createCapabilities(device)
AL.createCapabilities(alcCapabilities)
source = alGenSources()
buffers = IntArray(queuedBufferCount)
alGenBuffers(buffers)
if (source == 0 || alGetError() != AL_NO_ERROR) {
println("FieldAudioEngine: failed to create OpenAL source/buffers")
return
}
freeBuffers.addAll(buffers.toList())
applyLocalOutputGain()
alSourcei(source, AL_LOOPING, AL_FALSE)
outputActive = true
} catch (t: Throwable) {
println("FieldAudioEngine: output init failed: ${t.message}")
t.printStackTrace()
}
}
private fun initializeCapture() {
try {
val format = if (inputChannels == 1) org.lwjgl.openal.AL10.AL_FORMAT_MONO16 else org.lwjgl.openal.AL10.AL_FORMAT_STEREO16
captureDevice = alcCaptureOpenDevice(null as ByteBuffer?, sampleRate, format, sampleRate)
if (captureDevice == 0L) {
return
}
captureScratch = MemoryUtil.memAllocShort(bufferSize * inputChannels)
alcCaptureStart(captureDevice)
inputActive = true
} catch (t: Throwable) {
println("FieldAudioEngine: capture init failed: ${t.message}")
t.printStackTrace()
shutdownCapture()
}
}
private fun ensurePlaying() {
if (!outputActive) return
if (alGetSourcei(source, AL_BUFFERS_QUEUED) <= 0) return
if (alGetSourcei(source, AL_SOURCE_STATE) != AL_PLAYING) {
alSourcePlay(source)
}
}
private fun applyLocalOutputGain() {
if (source != 0) {
alSourcef(source, AL_GAIN, if (monitorLocalOutput) 1f else 0f)
}
}
private fun reclaimProcessedBuffers() {
if (!outputActive) return
val processed = alGetSourcei(source, AL_BUFFERS_PROCESSED)
repeat(processed.coerceAtLeast(0)) {
freeBuffers += alSourceUnqueueBuffers(source)
}
}
private fun queueNextBuffer() {
if (!outputActive || freeBuffers.isEmpty()) return
val bufferId = freeBuffers.removeFirst()
synthContext.advanceCycle()
val activeLink = linkOutput ?: LinkAudioInterface.currentOrNull()
val linkBlock = activeLink?.beginAudioBlock()
synthContext.linkTiming = linkBlock?.snapshot
pollCapture()
Buf.clear(mixL, 0, bufferSize)
Buf.clear(mixR, 0, bufferSize)
val snapshot = patches.values.toList()
snapshot.forEach { patch ->
patch.renderer.render(mixL, mixR, bufferSize, synthContext)
}
pcmBuffer.clear()
for (i in 0 until bufferSize) {
val left = (sanitizePcmSample(mixL[i]) * Short.MAX_VALUE).roundToInt().toShort()
val right = (sanitizePcmSample(mixR[i]) * Short.MAX_VALUE).roundToInt().toShort()
val base = i * 2
linkPcm[base] = left
linkPcm[base + 1] = right
pcmBuffer.putShort(left)
pcmBuffer.putShort(right)
}
pcmBuffer.flip()
linkOutput?.commitStereo(linkBlock, linkPcm, bufferSize, sampleRate)
alBufferData(bufferId, AL_FORMAT_STEREO16, pcmBuffer, sampleRate)
alSourceQueueBuffers(source, bufferId)
if (alGetError() != AL_NO_ERROR) {
freeBuffers += bufferId
}
}
private fun sanitizePcmSample(sample: Float): Float {
if (!sample.isFinite()) {
recordNonFiniteSample()
return 0f
}
return sample.coerceIn(-1f, 1f)
}
private fun recordNonFiniteSample() {
nonFiniteSamplesSanitized += 1
nonFiniteSamplesSinceLog += 1
val now = System.nanoTime()
if (now < nextNonFiniteLogAtNs) return
lprintln(
"warning,audio",
"FieldAudioEngine sanitized non-finite samples: +$nonFiniteSamplesSinceLog (total=$nonFiniteSamplesSanitized)"
)
nonFiniteSamplesSinceLog = 0L
nextNonFiniteLogAtNs = now + NON_FINITE_LOG_INTERVAL_NS
}
private fun pollCapture() {
if (!inputActive || captureDevice == 0L) {
captureState.clear()
return
}
val scratch = captureScratch ?: run {
captureState.clear()
return
}
val available = MemoryStack.stackPush().use { stack ->
val tmp = stack.mallocInt(1)
alcGetIntegerv(captureDevice, ALC_CAPTURE_SAMPLES, tmp)
tmp[0]
}
if (available <= 0) {
captureState.clear()
return
}
val framesToRead = minOf(available, bufferSize)
scratch.clear()
scratch.limit(framesToRead * inputChannels)
alcCaptureSamples(captureDevice, scratch, framesToRead)
captureState.update(scratch, framesToRead, inputChannels)
}
private fun shutdownCapture() {
if (captureDevice != 0L) {
alcCaptureStop(captureDevice)
alcCaptureCloseDevice(captureDevice)
captureDevice = 0L
}
captureScratch?.let {
MemoryUtil.memFree(it)
captureScratch = null
}
inputActive = false
}
private fun shutdownOutput() {
if (source != 0) {
runCatching { alSourceStop(source) }
}
val queued = if (source != 0) alGetSourcei(source, AL_BUFFERS_QUEUED) else 0
repeat(queued.coerceAtLeast(0)) {
runCatching { alSourceUnqueueBuffers(source) }
}
if (buffers.isNotEmpty()) {
alDeleteBuffers(buffers)
buffers = IntArray(0)
}
if (source != 0) {
alDeleteSources(source)
source = 0
}
if (context != 0L) {
alcMakeContextCurrent(0L)
alcDestroyContext(context)
context = 0L
}
if (device != 0L) {
alcCloseDevice(device)
device = 0L
}
outputActive = false
}
}
private class CaptureState(private val bufferSize: Int) {
private val lock = Any()
private var left = FloatArray(bufferSize)
private var right = FloatArray(bufferSize)
fun update(samples: ShortBuffer, frames: Int, channels: Int) {
synchronized(lock) {
val count = frames.coerceAtMost(bufferSize)
for (i in 0 until count) {
val base = i * channels
val l = samples.get(base).toFloat() / Short.MAX_VALUE
val r = if (channels > 1) samples.get(base + 1).toFloat() / Short.MAX_VALUE else l
left[i] = l
right[i] = r
}
for (i in count until bufferSize) {
left[i] = 0f
right[i] = 0f
}
}
}
fun clear() {
synchronized(lock) {
Buf.clear(left, 0, left.size)
Buf.clear(right, 0, right.size)
}
}
fun copy(channel: Int, output: FloatArray, numFrames: Int) {
synchronized(lock) {
val count = minOf(numFrames, bufferSize)
when (channel) {
0 -> System.arraycopy(left, 0, output, 0, count)
1 -> System.arraycopy(right, 0, output, 0, count)
else -> {
for (i in 0 until count) {
output[i] = (left[i] + right[i]) * 0.5f
}
}
}
for (i in count until numFrames) {
output[i] = 0f
}
}
}
}
private class CaptureSignal(
private val state: CaptureState,
private val channel: Int
) : Signal() {
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
state.copy(channel, output, numFrames)
}
}