fix(tvos): make EAC3 playback conform to Dolby's guidance

Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.

Audio session, per the guide's sequence:

- Adopt the long-form playback profile in one atomic call at app launch
  and activate the session there. The SDK only accepts that policy with
  category Playback, a Default/MoviePlayback/SpokenAudio mode and no
  options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
  system resolves it. Apple only resolves it for CarPlay and AirPlay, so
  an unresolved value means unknown, never "not Dolby".

Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):

- Add a sample-buffer arm. It reads the asset with AVAssetReader at
  outputSettings nil and hands the untouched compressed buffers and the
  untouched format description straight to the renderer, with a variant
  that rebuilds the description the way playback builds it. Every
  existing mode went through AVPlayer, so nothing exercised the path
  playback actually uses; this is what tells us whether the renderer or
  our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
  resolves the rendering mode and the supported channel layouts, so it is
  what makes those observations reachable at all, and the AVPlayer arms
  now allow external playback so every arm can be compared on the same
  destination.
- Add a session-mode toggle for the one profile difference between the
  guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
  descriptions, the magic cookie and the renderer status, and release the
  session on stop so a failed run cannot contaminate the next one.

Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.

This does not yet fix #1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
This commit is contained in:
edde746
2026-07-26 20:42:58 +02:00
parent a56b9a3dfb
commit 6c14049e95
62 changed files with 1743 additions and 148 deletions
+60 -4
View File
@@ -981,6 +981,33 @@ class Translations$settings$en {
/// en: 'Plays the test file with a known length. Needs the test file URL.'
String get atmosTestRawFileDescription => 'Plays the test file with a known length. Needs the test file URL.';
/// en: 'Sample-buffer renderer (native)'
String get atmosTestAsbarNative => 'Sample-buffer renderer (native)';
/// en: 'Feeds the file's untouched compressed audio straight to the system renderer. Needs the test file URL.'
String get atmosTestAsbarNativeDescription => 'Feeds the file\'s untouched compressed audio straight to the system renderer. Needs the test file URL.';
/// en: 'Sample-buffer renderer (rebuilt)'
String get atmosTestAsbarGenerated => 'Sample-buffer renderer (rebuilt)';
/// en: 'Same, but with the audio description rebuilt the way playback builds it. Needs the test file URL.'
String get atmosTestAsbarGeneratedDescription => 'Same, but with the audio description rebuilt the way playback builds it. Needs the test file URL.';
/// en: 'Use movie playback session mode'
String get atmosTestSessionMode => 'Use movie playback session mode';
/// en: 'Off uses the mode Dolby documents. On uses the mode playback used previously.'
String get atmosTestSessionModeDescription => 'Off uses the mode Dolby documents. On uses the mode playback used previously.';
/// en: 'Choose AirPlay output'
String get atmosTestShowRoutePicker => 'Choose AirPlay output';
/// en: 'Hide AirPlay output picker'
String get atmosTestHideRoutePicker => 'Hide AirPlay output picker';
/// en: 'Send the test to an AirPlay receiver. Only AirPlay reports the resolved audio mode.'
String get atmosTestRoutePickerDescription => 'Send the test to an AirPlay receiver. Only AirPlay reports the resolved audio mode.';
/// en: 'Stop test'
String get atmosTestStop => 'Stop test';
@@ -3643,6 +3670,21 @@ class Translations$videoSettings$en {
/// en: 'Audio Passthrough'
String get audioPassthrough => 'Audio Passthrough';
/// en: 'Dolby Atmos'
String get audioOutputDolbyAtmos => 'Dolby Atmos';
/// en: 'Dolby Audio'
String get audioOutputDolbyAudio => 'Dolby Audio';
/// en: 'Surround'
String get audioOutputSurround => 'Surround';
/// en: 'Spatial Audio'
String get audioOutputSpatial => 'Spatial Audio';
/// en: 'Stereo'
String get audioOutputStereo => 'Stereo';
/// en: 'Normalize Loudness'
String get audioNormalization => 'Normalize Loudness';
@@ -5445,6 +5487,15 @@ extension on Translations {
'settings.atmosTestRawStreamDescription' => 'Streams the test file exactly like in-player Atmos playback. Needs the test file URL.',
'settings.atmosTestRawFile' => 'Raw EAC3 file',
'settings.atmosTestRawFileDescription' => 'Plays the test file with a known length. Needs the test file URL.',
'settings.atmosTestAsbarNative' => 'Sample-buffer renderer (native)',
'settings.atmosTestAsbarNativeDescription' => 'Feeds the file\'s untouched compressed audio straight to the system renderer. Needs the test file URL.',
'settings.atmosTestAsbarGenerated' => 'Sample-buffer renderer (rebuilt)',
'settings.atmosTestAsbarGeneratedDescription' => 'Same, but with the audio description rebuilt the way playback builds it. Needs the test file URL.',
'settings.atmosTestSessionMode' => 'Use movie playback session mode',
'settings.atmosTestSessionModeDescription' => 'Off uses the mode Dolby documents. On uses the mode playback used previously.',
'settings.atmosTestShowRoutePicker' => 'Choose AirPlay output',
'settings.atmosTestHideRoutePicker' => 'Hide AirPlay output picker',
'settings.atmosTestRoutePickerDescription' => 'Send the test to an AirPlay receiver. Only AirPlay reports the resolved audio mode.',
'settings.atmosTestStop' => 'Stop test',
'settings.atmosTestUrl' => 'Test file URL',
'settings.atmosTestUrlDescription' => 'HTTP URL of a raw .ec3 Dolby Atmos file (e.g. extracted with ffmpeg)',
@@ -5669,6 +5720,8 @@ extension on Translations {
'videoControls.queue' => 'Queue',
'videoControls.noQueueItems' => 'No items in queue',
'videoControls.searchSubtitles' => 'Search Subtitles',
_ => null,
} ?? switch (path) {
'videoControls.language' => 'Language',
'videoControls.noSubtitlesFound' => 'No subtitles found',
'videoControls.noSubtitlesAvailable' => 'No subtitles available',
@@ -5678,8 +5731,6 @@ extension on Translations {
'videoControls.subtitleDownloadedNotApplied' => 'Subtitle downloaded, but it could not be selected',
'videoControls.subtitleDownloadFailed' => 'Failed to download subtitle',
'videoControls.searchLanguages' => 'Search languages...',
_ => null,
} ?? switch (path) {
'messages.markedAsWatched' => 'Marked as watched',
'messages.markedAsUnwatched' => 'Marked as unwatched',
'messages.markedAsWatchedOffline' => 'Marked as watched (will sync when online)',
@@ -6183,6 +6234,8 @@ extension on Translations {
'watchTogether.syncing' => 'Syncing...',
'watchTogether.joinWatchSession' => 'Join Watch Session',
'watchTogether.enterCodeHint' => 'Enter 5-character code',
_ => null,
} ?? switch (path) {
'watchTogether.pasteFromClipboard' => 'Paste from clipboard',
'watchTogether.pleaseEnterCode' => 'Please enter a session code',
'watchTogether.codeMustBe5Chars' => 'Session code must be 5 characters',
@@ -6192,8 +6245,6 @@ extension on Translations {
'watchTogether.sessionCodeCopied' => 'Session code copied to clipboard',
'watchTogether.relayUnreachable' => 'Relay server unreachable. ISP blocking may prevent Watch Together.',
'watchTogether.reconnectingToHost' => 'Reconnecting to host...',
_ => null,
} ?? switch (path) {
'watchTogether.currentPlayback' => 'Current Playback',
'watchTogether.joinCurrentPlayback' => 'Join Current Playback',
'watchTogether.joinCurrentPlaybackDescription' => 'Jump back into what the host is currently watching',
@@ -6403,6 +6454,11 @@ extension on Translations {
'videoSettings.audioOutput' => 'Audio Output',
'videoSettings.performanceOverlay' => 'Performance Overlay',
'videoSettings.audioPassthrough' => 'Audio Passthrough',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
'videoSettings.audioOutputSpatial' => 'Spatial Audio',
'videoSettings.audioOutputStereo' => 'Stereo',
'videoSettings.audioNormalization' => 'Normalize Loudness',
'videoSettings.audioDownmix' => 'Downmix to Stereo',
'performanceOverlay.color' => 'Color',