Skip to content

AetherEngine 1.3.0

Choose a tag to compare

@superuser404notfound superuser404notfound released this 22 May 20:57
· 462 commits to main since this release

AetherEngine 1.3.0

Five days of focused work on the audio bridge, long-session memory, Dolby Vision dispatch, and the producer / cache layer. 137 commits since 1.2.0. The big throughline: every "memory grows over time" report root-caused (URLSession task pool retention, subtitle cue accumulation, periodic muxer recycle), and the audio bridge gained a soundbar-compatible default mode after the FLAC-only path downmixed to stereo on Sonos / Samsung HW-Q / Bose installations.

Audio bridge: two modes

The AudioBridge (TrueHD / DTS / DTS-HD MA / MP3 / Opus / Vorbis / PCM / MP2 decode + re-encode) gained a mode selector. Host picks via LoadOptions.audioBridgeMode:

  • .surroundCompat (new default): EAC3 at 128 kbps per channel. 256 kbps stereo, 768 kbps 5.1. AVPlayer hands the encoded bitstream to HDMI; the sink decodes its own surround mix. Works on every modern AVR and soundbar including the LPCM-stereo-only ones (Sonos Arc, Samsung HW-Q, Bose).
  • .lossless (opt-in): FLAC up to 7.1, lossless. AVPlayer decodes to LPCM and routes via the active HDMI port. Needs an AVR that accepts multichannel LPCM (Denon, Marantz, NAD high-end).

EAC3+JOC stream-copy bypasses the bridge entirely; Atmos passthrough intact.

Per-channel bitrate scales dynamically with the resolved channel count (DrHurt's pointer on issue #4). Drops Opus 2.0 / MP3 bridge overhead from 640 kbps flat to 256 kbps. When the upstream FFmpeg PR 21668 for 7.1 EAC3 lands, the channel cap bumps from 6 to 8 and the bitrate auto-engages 1024 kbps without a code change.

dec3 / dac3 from packet bitstream

The 1.2.0 path manually reconstructed dec3 / dac3 from the first AC3 / EAC3 syncframe in the host before feeding the muxer. Brittle around HDMV PGS and DV-only MKV variants. Replaced with the mp4 muxer's native +delay_moov flag: the moov atom is deferred until the first fragment cut, by which point libavformat's handle_eac3 / handle_ac3 populates the sample-entry from the actual packet bitstream. EAC3-from-MKV without pre-parsed extradata now stream-copies cleanly. Muxer flag set is the leak-free trio: +empty_moov+default_base_moof+frag_custom+delay_moov.

ec+3 for EAC3+JOC Atmos

EAC3+JOC stream-copy now advertises CODECS="ec+3" in the playlist (Apple HLS Authoring Spec marker for Atmos via DD+) rather than plain ec-3. Matches the dec3 box's JOC flag and keeps AVPlayer's downstream routing consistent.

Dolby Vision Profile 5: dvh1 + dvcC always

DV5 dispatch no longer downgrades the sample entry to hvc1 on non-DV-capable panels. The IPT-PQ-c2 elementary stream needs the DV decoder for color conversion, and AVPlayer cannot engage that decoder without the dvh1 sample entry. Per DrHurt's #19 finding ("dvh1 sample entry + media playlist = correct colours on every panel"), DV5 now emits dvh1.05.<level> + dvcC always; routing forces media playlist when the panel cannot engage DV mode (master with bare dvh1.05 is rejected by tvOS 26's strict master-level codec filter with -11868).

DV8.1 and DV8.4 dispatch unchanged.

Memory

Long-session heap: bounded

1.2.0 had slow leaks that hit jetsam after 8-13 min on 4K HDR HEVC at ~25 Mbps. Root-caused across four places:

AVIOReader URLSession task pool retention. The long-lived URLSession retained completion-handler response Data inside its internal task pool until invalidation, growing with the cumulative bytes fetched. Replaced with delegate-based incremental chunk fetch on a shared session: the task object releases per-chunk references after the delegate ack returns, no monolithic body accumulates. Verified bounded over 5-min 4K HEVC sessions: ~100-140 MB resident regardless of cumulative bytes.

Foundation Data CoW aliasing. Data.append(other) keeps a CoW reference to the source dispatch_data even after the append. Replaced with explicit withUnsafeMutableBytes + memcpy so the URLSession-side buffer is released cleanly per chunk.

Periodic demuxer recycle. A 30-second timer that recycled the libavformat demuxer to bound its internal heap turned out to be the leak source itself (the new demuxer kept the old AVIO context alive via libavformat's internal state). Removed; demuxer lifetime is now the session's.

Bitmap subtitle cue retention. Each PGS / DVB / DVD cue carried a CGImage whose CGDataProvider retained the decoded RGBA pixel buffer for the cue's lifetime. A 2h Blu-ray with PGS English (~1500-2000 cues) grew the heap by several hundred MB over the session. Cues are now pruned 300 s past current source-PTS; bounded at ~22 MB for typical PGS. Far backward scrubs that pass the retention window get cues back through producer-restart re-emit (EmbeddedSubtitleDecoder.resetState clears the dedupe set).

4 MB chunks via delegate fetch

AVIOReader chunk size went from 64 MB to 4 MB. Smaller chunks mean snappier cold-start (~1-3 s savings on first frame) without re-triggering the task pool leak (delegate fetch fixed that). Demuxer reuse from the probe step into the segment producer halved the cold-start path too.

Producer / cache

Backward window 5 → 20 + hole-wait

SegmentCache.backwardWindow expanded from 5 to 20 segments. With tvOS Continuous Audio Connection active, AVPlayer commonly refetches ~7-10 segments backward for audio gapless handover; the old 5-segment window made every such refetch a cache miss that triggered a producer restart, each one resetting the bridge encoder PTS and producing audible glitches. The cache-miss decision also gained a 2 s wait before declaring an in-range cache hole, breaking the 7-restart cascade observed when AVPlayer requested sequential segments faster than the new producer could write them.

Single session-wide muxer with +frag_custom

1.2.0 ran a fresh mp4 muxer per segment to clear DTS state. Replaced with a single muxer for the producer's lifetime that cuts fragments via av_write_frame(ctx, nil) and rotates segment files in the FragmentSplitter sink. Forward-only segment routing plus DTS-based lookup means no cross-fragment DTS regressions even on B-frame-heavy sources.

HEVC pre-keyframe leading B-frame (RASL) drops

HEVC open-GOP CRA leading B-frames before the first keyframe at a restart position are dropped now. Without that, AVPlayer would stall with -12860 on Bluey-style remuxes where the restart landed mid-GOP.

Audio gate / bridge encoder PTS

Gate target rescaled into source TB, not bridge encoder TB. The producer's audio scan-forward gate previously computed its target dts in audio.inputTimeBase. For stream-copy that equaled the source's TB. For the FLAC / EAC3 bridge it equaled the encoder TB (1/48000), while incoming packet.dts was in matroska's source TB (1/1000), so the gate target landed 48× too far into the source. Fixed by rescaling into the source TB.

Bridge encoder PTS rebases off packet pts, not frame pts. Codecs with decoder priming samples (Opus preskip, AAC encoder delay) advance frame.pts by the preskip count. Rebasing the bridge encoder timeline off the advanced frame.pts forward-shifted FLAC output by preskip-count and opened the audio gate ahead of the video gate, stalling AVPlayer in waitingToPlay. Use packet.pts instead so the encoded output matches the source timeline regardless of trim.

Other

  • Custom HTTP headers via LoadOptions.httpHeaders (carries through every demux + segment fetch).
  • AV1 native pipeline on HW-AV1 hosts (M3+ Mac, iPhone 15 Pro+, future Apple TV chips) with dav1 codec-tag wiring for AV1+DV. Software dav1d path stays for AV1 on tvOS + older devices and unconditionally for VP9.
  • engine.sourceTime published for the host overlay (= currentTime + playlistShiftSeconds) so subtitle cue rendering aligns with what is on screen regardless of which producer session is active.
  • New diagnostics: DV source side-data log, video track dump on readyToPlay, EAC3 multichannel-route warning, FLAC bridge surround-vs-route warning, AVPacket alloc/free balance counter for leak diagnostics.

Migration notes

  • LoadOptions.audioBridgeMode defaults to .surroundCompat. Hosts that want FLAC must set .lossless explicitly. Sodalite UI added a Settings toggle.
  • No public API removals.

Engine pin

For Sodalite hosts: bump Package.resolved to dcc1c57 (or use the 1.3.0 tag).