CoreAudioSink

Audio output through CoreAudio, owned in C.

The device pulls. Its render callback runs on a real-time thread CoreAudio owns, and that thread must never be made to wait: it cannot allocate, cannot take a contended lock and cannot suspend. It also cannot enter Kotlin, and that is what this file is about.

What changed, and why it had to

This class used to install a Kotlin lambda as the render callback, whose first instruction was refCon.asStableRef<CoreAudioSink>().get(). That made the device's real-time thread a Kotlin mutator the garbage collector has to stop at a safepoint. Thirteen long-lived objects, fourteen atomic wrappers, two virtual interface calls, a scalar copy loop and up to five transient cinterop views were on that path, and worst measured stop-the-world pauses on the development machine were 63 to 256 microseconds against a 10.67 millisecond period at 512 frames and 48 kHz. The honest statement was never that audio glitched; it was that the deadline depended on a pause nobody had bounded.

So the callback, the AudioUnit and the sample ring all moved into kiteplayer-rt, and what is left here owns two opaque handles and forwards six lifecycle calls. Nothing in this file touches an AudioUnit, nothing here runs on the device's thread, and there is no StableRef anywhere in it. kiteplayer-rt/native/scripts/render-audit.sh proves the first claim from the shipped object's own symbol table rather than from this paragraph.

The timestamp that makes synchronisation work

Unchanged in substance, and now computed in C. CoreAudio hands the callback a timestamp whose host time is when the buffer being filled reaches the device. Converted through a mach_timebase_info cached when the sink was created, and offset by the buffer's own length, that is the instant its last frame becomes audible, which is exactly what the audio clock anchors to. The timestamp says which of its fields mean anything, so the host time is used only when the device flags it valid; otherwise the anchor is estimated from the clock and the estimate is counted, which is what estimatedAnchors reports.

Nothing here estimates a device latency, because nothing needs to. ffplay assumes every device holds exactly two buffer periods, and that assumption is the largest single source of fixed A/V offset in it.

Silence, which is now owned entirely in C

This class used to fill the tail of a short read with silence and count an underrun, and the ring did the same thing one level down; the old comment called that duplication deliberate, on the grounds that the render callback could be absent. There is no absent callback now: the callback is a C function installed for the life of the sink. Normal starvation and end-of-stream silence, plus the underrun counter, live in kprt_ring_render. The callback itself zeroes only cases the ring renderer cannot address safely: a missing ring during teardown or a malformed device buffer list. Kotlin writes no silence on the device path.

open is not the entry point

This sink implements NativeRingAudioSink, so it is opened through openWithRing and it refuses open. That refusal is deliberate and is not a rough edge: a device whose C callback ignored the Kotlin lambda it was handed would play correctly while the caller believed its callback was being called, and silent disagreement is worse than a loud failure. The engine never calls open on such a sink; openAudioPath in kiteplayer-core is what makes the choice.

Opening is transactional

Defect D23's rule, moved into C with the device. Either kprt_sink_create returns a sink that owns an initialised audio unit, or it returns a verdict having disposed everything it created. A failed open therefore leaves this object owning nothing, which is what retainedResources reports and what appleTest asserts.

The output device

On macOS an unbound sink uses the DefaultOutput unit, which follows the system default output device by itself. So an open sink watches the default output and reports each change as AudioSinkEvent.DeviceChanged, which the engine turns into a warning, and it keeps playing. A sink bound to one device stays on it and watches that device instead. When the device disappears, the sink reports AudioSinkEvent.Failed with PlaybackError.AudioDeviceUnavailable, and the player fails with that error. The failure stays on events, so a collector that subscribes after the loss still receives it. iOS has no default device of its own: the audio session owns the route.

Threading

openWithRing, start, stop, drain and setPaused belong to the session owner, which is the same confinement AudioPlayback documents for the calls it makes into a sink. close belongs to the owner as well but additionally takes an internal lock, and latencyNanos and the diagnostic counters take it too, because those are the members another thread may call: the two fields they read are C pointers that close frees, and the lock is what orders the free after the read. See the note on the lock itself for the AddressSanitizer report that made this necessary rather than tidy.

Constructors

Link copied to clipboard
constructor(clock: MonotonicClock = AppleHostClock)

Preserves the original clock-first API and uses KitePlayer's managed playback policy.

constructor(policy: AppleAudioSessionPolicy, clock: MonotonicClock = AppleHostClock)

Selects who owns the process-wide iOS audio session while retaining the Apple host clock.

Properties

Link copied to clipboard
open override val cutsSoundOnStop: Boolean

Stopping the output unit cuts the sound where it is, so the engine fades it first (#486).

Link copied to clipboard
open override val deviceBufferFrames: Int

The device's period, as C reports it.

Link copied to clipboard
open override val events: Flow<AudioSinkEvent>
Link copied to clipboard
open override val latencyQuality: LatencyQuality

Estimated, not Exact, and the distinction is deliberate.

Link copied to clipboard

Functions

Link copied to clipboard
open override fun close()

Stops, uninitialises, disposes, and only then releases the ring, in C and in that order. On iOS, the audio-session lease is released after that complete C teardown, never before RemoteIO stops.

Link copied to clipboard
open suspend override fun drain()

CoreAudio has no drain of its own. Once the callback stops supplying samples the device plays what it already holds and then silence, so waiting out the buffer already handed over is exactly the drain the engine needs before declaring the end of the media.

Link copied to clipboard
open override fun latencyNanos(): Long
Link copied to clipboard
open suspend override fun open(request: AudioFormat, render: AudioRenderCallback): AudioFormat

Refused, and the message says what to call instead. See the class note.

Link copied to clipboard
open suspend override fun openWithRing(request: AudioFormat, capacityFrames: (AudioFormat) -> Int): NativeRingHandoff
Link copied to clipboard
open override fun setContent(content: AudioContent)
Link copied to clipboard
open suspend override fun setPaused(paused: Boolean): Boolean

Stopping the unit keeps the device open, so nothing buffered is lost and resuming is quick.

Link copied to clipboard
open suspend override fun start()
Link copied to clipboard
open suspend override fun stop()