diff --git a/Sources/AetherEngine/PlayerState.swift b/Sources/AetherEngine/PlayerState.swift index 926333f9..c11d05a5 100644 --- a/Sources/AetherEngine/PlayerState.swift +++ b/Sources/AetherEngine/PlayerState.swift @@ -109,9 +109,11 @@ public enum AudioDelivery: String, Sendable, Equatable, CaseIterable { /// The source's audio bitstream is muxed into fMP4 unchanged: Atmos, DTS-HD and every other /// bitstream reach the renderer exactly as authored. case streamCopy - /// The audio is decoded and re-encoded (FLAC or E-AC-3) for the fMP4 pipeline, because its codec - /// is not fMP4-legal or AVPlayer rejects it there. Lossless for the bed channels; object metadata - /// in a TrueHD-MAT or JOC bitstream does not survive the PCM intermediate. + /// The audio is decoded and re-encoded for the fMP4 pipeline, because its codec is not fMP4-legal + /// or AVPlayer rejects it there: to FLAC (lossless bed channels) or E-AC-3, where object metadata + /// in a TrueHD-MAT or JOC bitstream does not survive the PCM intermediate, or, under + /// `LoadOptions.objectAudioRendering`, to lossy APAC with the TrueHD Atmos objects rendered into + /// the speaker bed. case bridged /// libavcodec decodes the audio and the engine renders it itself (the software path and the /// software audio-only host). diff --git a/Tests/AetherEngineTests/DocumentedConstantsTests.swift b/Tests/AetherEngineTests/DocumentedConstantsTests.swift index 8a0cc110..a3bd3712 100644 --- a/Tests/AetherEngineTests/DocumentedConstantsTests.swift +++ b/Tests/AetherEngineTests/DocumentedConstantsTests.swift @@ -229,8 +229,12 @@ final class DocumentedConstantsTests: XCTestCase { guard #available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) else { return } XCTAssertEqual(SpatialAudioBridge.bitRatePerChannel, 320_000, "docs/formats.md quotes 320 kbps per bed channel") + XCTAssertEqual(APACSampleEntry.codecsString(channelCount: 8), "apac.31.02", + "docs/formats.md and cli.md quote apac.31.02 for 5.1.2") + XCTAssertEqual(APACSampleEntry.codecsString(channelCount: 10), "apac.31.03", + "docs/formats.md and cli.md quote apac.31.03 for 10 channels") XCTAssertEqual(APACSampleEntry.codecsString(channelCount: 12), "apac.31.03", - "docs/formats.md and cli.md quote apac.31.03 for up to 12 channels") + "docs/formats.md and cli.md quote apac.31.03 for 12 channels") XCTAssertEqual(APACSampleEntry.codecsString(channelCount: 16), "apac.31.04", "docs/formats.md and cli.md quote apac.31.04 for 16 channels") } diff --git a/docs/api.md b/docs/api.md index d0482e5b..3e54cc29 100644 --- a/docs/api.md +++ b/docs/api.md @@ -322,7 +322,7 @@ typed fact rather than as something to reconstruct (AE#462). | `.none` | no session | | `.noAudioInSource` | the source carries no audio track, or none was selected | | `.streamCopy` | the source bitstream is muxed into fMP4 unchanged (Atmos, DTS-HD and everything else reach the renderer as authored) | -| `.bridged` | decoded and re-encoded to FLAC or E-AC-3 for the fMP4 pipeline; lossless for the bed channels, object metadata does not survive the PCM intermediate | +| `.bridged` | decoded and re-encoded for the fMP4 pipeline: to FLAC (lossless bed channels) or E-AC-3, where object metadata does not survive the PCM intermediate, or, under `objectAudioRendering`, to lossy APAC with the TrueHD Atmos objects rendered into the speaker bed | | `.decoded` | libavcodec decodes and the engine renders it (the software path, the FFmpeg audio-only host) | | `.droppedNoPipeline` | the source HAS audio and none of it could be delivered: no decoder for it in this build, or the bridge could not be built or could not write its header. The session plays video-only and silently | | `.playerManaged` | AVFoundation owns the audio (the remote-HLS bypass, the native audio-only host). The engine has no pipeline of its own to classify and does not answer on AVFoundation's behalf | @@ -960,7 +960,7 @@ All flags default to safe values; the table is the full set. Depth for the media | `nativeRemoteHLSIngestFallback` | true | The #168 / #293 carriage recovery and the #363 401/403 bypass refusal recovery. Setting it false turns both off. | | `audioOnly` | false | Lean audio pipeline, no video machinery. Also set automatically when the probe finds no video stream. | | `audioBridgeMode` | `.surroundCompat` | Bridge encoder for codecs that cannot stream-copy into fMP4. `.surroundCompat` uses EAC3 for a source with more than two channels and FLAC for one with two or fewer (no surround to carry). `.lossless` uses FLAC up to 7.1 throughout and needs a sink that accepts multichannel LPCM. | -| `objectAudioRendering` | `.off` | TrueHD Atmos delivery. `.apac(SpatialSpeakerLayout)` renders a TrueHD Atmos track's objects into that speaker bed (`SpatialSpeakerLayout` 5.1.2, 5.1.4, 7.1.2, 7.1.4 or 9.1.6, positions described by `SpatialSpeaker`) and delivers it as lossy APAC, which reaches an Atmos receiver as Dolby MAT with its heights; see `ObjectAudioRendering` and [formats.md › TrueHD Atmos (object rendering)](formats.md#truehd-atmos-object-rendering). TrueHD without Atmos, OS 25 and earlier, and any start-up failure keep `audioBridgeMode`. | +| `objectAudioRendering` | `.off` | TrueHD Atmos delivery. `.apac(SpatialSpeakerLayout)` renders a TrueHD Atmos track's objects into that speaker bed (`SpatialSpeakerLayout` 5.1.2, 5.1.4, 7.1.2, 7.1.4 or 9.1.6, positions described by `SpatialSpeaker`) and delivers it as lossy APAC, which reaches an Atmos receiver as Dolby MAT with its heights; see `ObjectAudioRendering` and [formats.md › TrueHD Atmos (object rendering)](formats.md#truehd-atmos-object-rendering). TrueHD without Atmos, OS versions before 26 (iOS, tvOS, macOS and visionOS 26), and any start-up failure keep `audioBridgeMode`. | | `confirmAtmos` | false | Background per-track JOC confirmation, republishing `audioTracks` as tracks confirm. Never on the start path; skipped for live and forward-only readers. | | `preferredAudioLanguages` | empty | First-frame audio pick from the engine's single probe. Ordered BCP-47 / ISO 639 tags; region and script are normalized and rank within one preference (#590). An explicit `audioSourceStreamIndex` still wins. | | `preferredSubtitleLanguages` | empty | Post-load subtitle activation on the host-overlay path. Ranks language specificity (an explicitly opposite script is rejected, not demoted) before the descriptor axis. Pure convenience: no reload and no pre-probe, unlike the audio equivalent. | diff --git a/docs/cli.md b/docs/cli.md index aaf200aa..4ffe2cae 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -70,7 +70,7 @@ open 'http://127.0.0.1:/master.m3u8' # macOS QuickTime `--start-position S` starts the session at S seconds, the resume anchor a host passes to `load(url:startPosition:)`. Also available on `play`. -`--atmos-bed ` is `LoadOptions.objectAudioRendering = .apac(layout)` (`5.1.2`, `5.1.4`, `7.1.2`, `7.1.4` or `9.1.6`): a TrueHD Atmos track is rendered into that bed and served as APAC, so the master reads `CODECS="…,apac.31.03"` (up to 12 channels) or `apac.31.04` (16) and the log carries `TrueHD Atmos: objects rendered into …`. `--audio-index ` serves source stream `n`, as a host's audio picker would; without it the engine prefers an E-AC-3 JOC track when the file has one. Dolby's Unfold demo: `serve --atmos-bed 7.1.4 --audio-index 1 dolby-unfold-lossless.m2ts`. +`--atmos-bed ` is `LoadOptions.objectAudioRendering = .apac(layout)` (`5.1.2`, `5.1.4`, `7.1.2`, `7.1.4` or `9.1.6`): a TrueHD Atmos track is rendered into that bed and served as APAC, so the master reads `CODECS="…,apac.31.02"` (5.1.2, 8 channels), `apac.31.03` (10 or 12 channels) or `apac.31.04` (9.1.6, 16 channels) and the log carries `TrueHD Atmos: objects rendered into …`. `--audio-index ` serves source stream `n`, as a host's audio picker would; without it the engine prefers an E-AC-3 JOC track when the file has one. Dolby's Unfold demo: `serve --atmos-bed 7.1.4 --audio-index 1 dolby-unfold-lossless.m2ts`. `--audio-delay ` parks the server with the AE#464 audio offset already in its muxer, which is how the DELIVERED offset is measured rather than argued about: fetch the media playlist and walk its segments, then read the first audio and video packet timestamps out of `init.mp4` + a segment with `ffprobe -show_entries packet=stream_index,pts_time`. On a 30 fps H.264 + AAC fixture the source's own alignment is +21.8 ms, `--audio-delay 200` reads +221.8 ms and `--audio-delay -150` reads -128.2 ms, i.e. exactly the offset asked for, with the video timestamp unchanged in every arm. diff --git a/docs/formats.md b/docs/formats.md index ecc03914..7e42a4a8 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -295,7 +295,7 @@ Matroska CodecPrivate doesn't usually carry the pre-parsed `dec3` / `dac3` box c Off by default. With `LoadOptions.objectAudioRendering = .apac(layout)`, a TrueHD track FFmpeg marks as Atmos (`AV_PROFILE_TRUEHD_ATMOS`, 48 kHz) skips the channel bridges: `SpatialAudioBridge` decodes the object presentation (beds, objects and their positions), renders it into the host's speaker bed (5.1.2, 5.1.4, 7.1.2, 7.1.4 or 9.1.6) with a constant-power allocentric panner, and encodes that bed as Apple Positional Audio (APAC, 320 kbps per bed channel) through `AVAudioConverter`. tvOS decodes APAC and sends it to an Atmos receiver as Dolby MAT, heights included. The trade is a lossy encode in place of the lossless 7.1 presentation, which is why the host opts in. Requires OS 26 (the APAC encoder API); on anything older, or if the bridge cannot start, the session falls back to `audioBridgeMode` with its audio intact. -libavformat cannot write APAC (its `apac` codec id is an unrelated legacy codec), so the muxer is given an ALAC stand-in and the init segment's sound sample entry is replaced with an `apac` entry carrying the encoder's magic cookie, which is the complete `dapa` box (`APACSampleEntry`). The master playlist advertises `apac.31.LL`: profile 31 (multichannel) and the channel-count level, `03` for up to 12 channels and `04` for up to 24; AVPlayer rejects a bare `apac`. Every APAC packet the encoder produces is independently decodable, so every segment starts on an Audio Sync Packet. The encoder runs with dynamic range control off: its DRC analysis otherwise holds 1.56 s (`.capture`) or more than 5 s (`.movie`) of audio before the first packet, which would leave the first segment after a seek without sound. The encoder's 2048 frames of priming stay in the stream: AVFoundation presents an APAC packet's audio 2048 frames before its timestamp, so the priming packets take the source position's timestamp and the first content frame plays on it. +libavformat cannot write APAC (its `apac` codec id is an unrelated legacy codec), so the muxer is given an ALAC stand-in and the init segment's sound sample entry is replaced with an `apac` entry carrying the encoder's magic cookie, which is the complete `dapa` box (`APACSampleEntry`). The master playlist advertises `apac.31.LL`: profile 31 (multichannel) and the channel-count level, `02` for 8 channels (5.1.2), `03` for 9 to 12 channels and `04` for 13 to 24; AVPlayer rejects a bare `apac`. Every APAC packet the encoder produces is independently decodable, so every segment starts on an Audio Sync Packet. The encoder runs with dynamic range control off: its DRC analysis otherwise holds 1.56 s (`.capture`) or more than 5 s (`.movie`) of audio before the first packet, which would leave the first segment after a seek without sound. The encoder's 2048 frames of priming stay in the stream: AVFoundation presents an APAC packet's audio 2048 frames before its timestamp, so the priming packets take the source position's timestamp and the first content frame plays on it. ## Subtitles