diff --git a/.github/workflows/release-combined.yml b/.github/workflows/release-combined.yml
index 3aa04377..c3a0afeb 100644
--- a/.github/workflows/release-combined.yml
+++ b/.github/workflows/release-combined.yml
@@ -33,6 +33,9 @@ jobs:
fi
echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV"
+ - name: Test the spatial audio output
+ run: ./scripts/test-spatial-audio-output.sh
+
- name: Install dependencies
run: |
brew install autoconf automake libtool ninja
@@ -49,6 +52,16 @@ jobs:
- name: Create combined MPVKit.xcframework
run: ./Sources/BuildScripts/create-combined-framework.sh
+ # Without FFmpeg's spdif muxer, mpv's passthrough decoder fails on its
+ # first packet and the audio track dies, so the spatial output never opens.
+ - name: Check that passthrough is built
+ run: |
+ for lib in dist/MPVKit-combined/xcframework/MPVKit.xcframework/*/MPVKit.framework/MPVKit; do
+ # grep -c reads to the end, so nm never writes into a closed pipe
+ # (grep -q would, and pipefail would then fail a good build).
+ [ "$(nm "$lib" | grep -c ff_spdif_muxer || true)" -gt 0 ] || { echo "no spdif muxer in $lib"; exit 1; }
+ done
+
- name: Upload binary to GitHub Release
if: github.event_name != 'pull_request'
uses: softprops/action-gh-release@v2
diff --git a/Sources/BuildScripts/XCFrameworkBuild/main.swift b/Sources/BuildScripts/XCFrameworkBuild/main.swift
index 1888591b..b56efe65 100644
--- a/Sources/BuildScripts/XCFrameworkBuild/main.swift
+++ b/Sources/BuildScripts/XCFrameworkBuild/main.swift
@@ -709,6 +709,8 @@ private class BuildFFMPEG: BaseBuild {
"--enable-muxer=flac", "--enable-muxer=dash", "--enable-muxer=hevc",
"--enable-muxer=m4v", "--enable-muxer=matroska", "--enable-muxer=mov", "--enable-muxer=mp4",
"--enable-muxer=mpegts", "--enable-muxer=webm*",
+ // mpv's passthrough (--audio-spdif) packs audio into IEC 61937 with it.
+ "--enable-muxer=spdif",
// ./configure --list-encoders
"--disable-encoders",
"--enable-encoder=aac", "--enable-encoder=alac", "--enable-encoder=flac", "--enable-encoder=pcm*",
diff --git a/Sources/BuildScripts/create-combined-framework.sh b/Sources/BuildScripts/create-combined-framework.sh
index 71ad7ced..7e8ca927 100755
--- a/Sources/BuildScripts/create-combined-framework.sh
+++ b/Sources/BuildScripts/create-combined-framework.sh
@@ -240,6 +240,7 @@ for platform in ios isimulator tvos tvsimulator; do
#include "mpv/render.h"
#include "mpv/render_gl.h"
#include "mpv/stream_cb.h"
+#include "mpv/spatial_audio.h"
#endif /* MPVKit_h */
UMBRELLA
diff --git a/Sources/BuildScripts/patch/libmpv/0007-spatial-audio-output.patch b/Sources/BuildScripts/patch/libmpv/0007-spatial-audio-output.patch
new file mode 100644
index 00000000..4f750659
--- /dev/null
+++ b/Sources/BuildScripts/patch/libmpv/0007-spatial-audio-output.patch
@@ -0,0 +1,517 @@
+diff --git a/audio/out/ao.c b/audio/out/ao.c
+index 4e5e9e3..ef2d124 100644
+--- a/audio/out/ao.c
++++ b/audio/out/ao.c
+@@ -41,6 +41,7 @@ extern const struct ao_driver audio_out_audiounit;
+ extern const struct ao_driver audio_out_coreaudio;
+ extern const struct ao_driver audio_out_coreaudio_exclusive;
+ extern const struct ao_driver audio_out_avfoundation;
++extern const struct ao_driver audio_out_spatial;
+ extern const struct ao_driver audio_out_rsound;
+ extern const struct ao_driver audio_out_pipewire;
+ extern const struct ao_driver audio_out_sndio;
+@@ -107,6 +108,9 @@ static const struct ao_driver * const audio_out_drivers[] = {
+ &audio_out_null,
+ #if HAVE_COREAUDIO
+ &audio_out_coreaudio_exclusive,
++#endif
++#if HAVE_AVFOUNDATION
++ &audio_out_spatial,
+ #endif
+ &audio_out_pcm,
+ &audio_out_lavc,
+@@ -613,6 +617,8 @@ void ao_print_devices(struct mpv_global *global, struct mp_log *log,
+ void ao_set_gain(struct ao *ao, float gain)
+ {
+ atomic_store(&ao->gain, gain);
++ if (ao->driver->set_gain)
++ ao->driver->set_gain(ao, gain);
+ }
+
+ #define MUL_GAIN_i(d, num_samples, gain, low, center, high) \
+diff --git a/audio/out/ao_spatial.m b/audio/out/ao_spatial.m
+new file mode 100644
+index 0000000..18d3080
+--- /dev/null
++++ b/audio/out/ao_spatial.m
+@@ -0,0 +1,312 @@
++/*
++ * This file is part of mpv.
++ *
++ * mpv is free software; you can redistribute it and/or
++ * modify it under the terms of the GNU Lesser General Public
++ * License as published by the Free Software Foundation; either
++ * version 2.1 of the License, or (at your option) any later version.
++ *
++ * mpv is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU Lesser General Public License for more details.
++ *
++ * You should have received a copy of the GNU Lesser General Public
++ * License along with mpv. If not, see .
++ */
++
++// Hands TrueHD and DTS-HD passthrough to the host application, which renders
++// it itself (see include/mpv/spatial_audio.h), and keeps mpv's audio clock on
++// the AVSampleBufferRenderSynchronizer the host renders into.
++//
++// A stream the host fails on falls back to PCM the way avfoundation's
++// compressed sink does: the format is latched and the output reloaded, the
++// reopen refuses it, and mpv decodes the track instead. A later PCM
++// initialization clears the latch, so the next track or file tries again.
++
++#include
++
++#include "ao.h"
++#include "audio/format.h"
++#include "common/common.h"
++#include "common/msg.h"
++#include "internal.h"
++#include "mpv/spatial_audio.h"
++#include "options/m_option.h"
++
++#import
++#import
++
++struct priv {
++ int64_t sink_address;
++ const mpv_spatial_audio_sink *sink;
++ AVSampleBufferRenderSynchronizer *synchronizer;
++ // Nudges the ao thread while the clock waits for the host, which the
++ // thread would otherwise only notice at its next refill.
++ dispatch_queue_t queue;
++ dispatch_source_t wakeup_timer;
++ atomic_bool waiting;
++ // The current timeline's start on the synchronizer clock, and the samples
++ // written since.
++ CMTime timeline;
++ int64_t written;
++ bool playing;
++ bool paused;
++ bool clock_running;
++ // The host has finished this timeline; the next write begins another.
++ bool ended;
++ bool reload_requested;
++};
++
++static _Atomic bool spatial_format_latch[2];
++
++static int spatial_latch_index(int format)
++{
++ return format == AF_FORMAT_S_DTSHD;
++}
++
++static int64_t ns_from_time(CMTime time)
++{
++ return CMTimeConvertScale(time, 1000000000, kCMTimeRoundingMethod_Default).value;
++}
++
++// Arms the timer that nudges the ao thread only while the clock waits.
++static void set_waiting(struct ao *ao, bool waiting)
++{
++ struct priv *p = ao->priv;
++
++ atomic_store(&p->waiting, waiting);
++ dispatch_source_set_timer(p->wakeup_timer,
++ waiting ? dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_MSEC) : DISPATCH_TIME_FOREVER,
++ 10 * NSEC_PER_MSEC, NSEC_PER_MSEC);
++}
++
++// Audio written but not yet played. While the clock waits, that is all of it,
++// so mpv's audio position, and with it the video, waits too.
++static int64_t queued_ns(struct ao *ao)
++{
++ struct priv *p = ao->priv;
++
++ // Split so that days of 192 kHz audio stay within int64.
++ int64_t written = p->written / ao->samplerate * INT64_C(1000000000) +
++ p->written % ao->samplerate * INT64_C(1000000000) / ao->samplerate;
++ int64_t now = ns_from_time(CMTimebaseGetTime(p->synchronizer.timebase));
++ return MPMAX(ns_from_time(p->timeline) + written - now, 0);
++}
++
++static void begin_timeline(struct ao *ao)
++{
++ struct priv *p = ao->priv;
++
++ [p->synchronizer setRate:0];
++ p->timeline = CMTimebaseGetTime(p->synchronizer.timebase);
++ p->written = 0;
++ p->playing = false;
++ p->paused = false;
++ p->clock_running = false;
++ p->ended = false;
++ set_waiting(ao, false);
++ p->sink->begin(p->sink->ctx, ns_from_time(p->timeline));
++}
++
++static int init(struct ao *ao)
++{
++ struct priv *p = ao->priv;
++
++ p->sink = (const mpv_spatial_audio_sink *)(intptr_t)p->sink_address;
++ if (!p->sink || p->sink->version != MPV_SPATIAL_AUDIO_SINK_VERSION) {
++ MP_VERBOSE(ao, "no host sink (--ao-spatial-sink)\n");
++ return -1;
++ }
++
++ mpv_spatial_audio_codec codec;
++ if (ao->format == AF_FORMAT_S_TRUEHD) {
++ codec = MPV_SPATIAL_AUDIO_CODEC_TRUEHD;
++ } else if (ao->format == AF_FORMAT_S_DTSHD) {
++ codec = MPV_SPATIAL_AUDIO_CODEC_DTSHD;
++ } else {
++ if (!af_fmt_is_spdif(ao->format)) {
++ atomic_store(&spatial_format_latch[0], false);
++ atomic_store(&spatial_format_latch[1], false);
++ }
++ MP_VERBOSE(ao, "only TrueHD and DTS-HD passthrough are handed to the host\n");
++ return -1;
++ }
++ if (atomic_load(&spatial_format_latch[spatial_latch_index(ao->format)])) {
++ MP_INFO(ao, "%s failed in the host; decoding it instead\n",
++ af_fmt_to_str(ao->format));
++ return -1;
++ }
++
++ p->synchronizer = [[AVSampleBufferRenderSynchronizer alloc] init];
++ if (!p->sink->open(p->sink->ctx, codec, (void *)p->synchronizer)) {
++ MP_VERBOSE(ao, "the host refused the stream\n");
++ [p->synchronizer release];
++ p->synchronizer = nil;
++ return -1;
++ }
++
++ p->sink->set_volume(p->sink->ctx, atomic_load(&ao->gain));
++
++ // The host's encoder holds 1.5 s before its first output, so let mpv
++ // queue well beyond that.
++ ao->device_buffer = ao->samplerate * 3;
++
++ p->queue = dispatch_queue_create("mpv spatial audio", DISPATCH_QUEUE_SERIAL);
++ p->wakeup_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, p->queue);
++ dispatch_source_set_timer(p->wakeup_timer, DISPATCH_TIME_FOREVER, 0, 0);
++ dispatch_source_set_event_handler(p->wakeup_timer, ^{
++ if (atomic_load(&p->waiting))
++ ao_wakeup(ao);
++ });
++ dispatch_resume(p->wakeup_timer);
++
++ begin_timeline(ao);
++ return 0;
++}
++
++static void uninit(struct ao *ao)
++{
++ struct priv *p = ao->priv;
++
++ // Not set when init() failed.
++ if (!p->queue)
++ return;
++ dispatch_source_cancel(p->wakeup_timer);
++ // A handler already running finishes before this returns.
++ dispatch_sync(p->queue, ^{});
++ dispatch_release(p->wakeup_timer);
++ dispatch_release(p->queue);
++
++ [p->synchronizer setRate:0];
++ p->sink->close(p->sink->ctx);
++ [p->synchronizer release];
++}
++
++static void reset(struct ao *ao)
++{
++ begin_timeline(ao);
++}
++
++static void start(struct ao *ao)
++{
++ struct priv *p = ao->priv;
++
++ p->playing = true;
++ set_waiting(ao, !p->paused && !p->clock_running);
++}
++
++static bool set_pause(struct ao *ao, bool paused)
++{
++ struct priv *p = ao->priv;
++
++ p->paused = paused;
++ if (p->clock_running)
++ [p->synchronizer setRate:paused ? 0 : 1];
++ set_waiting(ao, p->playing && !paused && !p->clock_running);
++ return true;
++}
++
++static bool audio_write(struct ao *ao, void **data, int samples)
++{
++ struct priv *p = ao->priv;
++
++ // After an underrun the clock has run past everything written, so
++ // continuing that timeline would place new audio in the past. After the
++ // end (gapless playback goes on to the next file without a reset), the
++ // host takes no more audio on it. Either way a new timeline begins, and
++ // playback stays active: mpv calls start() only if it saw the underrun.
++ if (p->ended || (p->clock_running && queued_ns(ao) == 0)) {
++ MP_VERBOSE(ao, "beginning a new timeline\n");
++ bool playing = p->playing;
++ begin_timeline(ao);
++ p->playing = playing;
++ set_waiting(ao, playing);
++ }
++ p->sink->write(p->sink->ctx, data[0], (size_t)samples * ao->sstride);
++ p->written += samples;
++ return true;
++}
++
++// The host holds back the start of a stream, and its encoder the latest
++// audio, until it has more; at the end it has to let both go.
++static void end_of_stream(struct ao *ao)
++{
++ struct priv *p = ao->priv;
++
++ p->ended = true;
++ p->sink->end(p->sink->ctx);
++}
++
++// mpv's software volume never reaches a bitstream, so the host applies it.
++// mpv calls this whenever it changes, paused or not.
++static void set_gain(struct ao *ao, float gain)
++{
++ struct priv *p = ao->priv;
++
++ // ao_alloc() sets the gain before init() has opened the host.
++ if (!p->queue)
++ return;
++ p->sink->set_volume(p->sink->ctx, gain);
++}
++
++static void get_state(struct ao *ao, struct mp_pcm_state *state)
++{
++ struct priv *p = ao->priv;
++
++ if (p->sink->has_failed(p->sink->ctx) && !p->reload_requested) {
++ MP_WARN(ao, "the host failed to render the stream, falling back to PCM\n");
++ p->reload_requested = true;
++ atomic_store(&spatial_format_latch[spatial_latch_index(ao->format)], true);
++ ao_request_reload(ao);
++ }
++
++ if (p->playing && !p->paused && !p->clock_running &&
++ p->sink->is_ready(p->sink->ctx))
++ {
++ [p->synchronizer setRate:1 time:p->timeline];
++ p->clock_running = true;
++ set_waiting(ao, false);
++ }
++
++ int64_t queued = queued_ns(ao);
++
++ // After the end mpv writes nothing until this timeline has played out, and
++ // its thread would sleep a quarter of the buffer before noticing, holding
++ // up EOF and a gapless next file. Paused, nothing plays out.
++ bool draining = p->ended && !p->paused && queued > 0;
++ if (p->ended && atomic_load(&p->waiting) != draining)
++ set_waiting(ao, draining);
++
++ state->queued_samples = queued * ao->samplerate / INT64_C(1000000000);
++ int period = ao->samplerate / 100;
++ state->free_samples = MPMAX(ao->device_buffer - state->queued_samples, 0)
++ / period * period;
++ // The next file's audio waits until this one has played out.
++ if (p->ended && queued > 0)
++ state->free_samples = 0;
++ state->delay = queued / 1e9;
++ state->playing = p->playing && queued > 0;
++}
++
++#define OPT_BASE_STRUCT struct priv
++
++const struct ao_driver audio_out_spatial = {
++ .description = "TrueHD and DTS-HD passthrough to the host application",
++ .name = "spatial",
++ .init = init,
++ .uninit = uninit,
++ .reset = reset,
++ .start = start,
++ .set_pause = set_pause,
++ .write = audio_write,
++ .get_state = get_state,
++ .end_of_stream = end_of_stream,
++ .set_gain = set_gain,
++ .priv_size = sizeof(struct priv),
++ .options = (const struct m_option[]) {
++ {"sink", OPT_INT64(sink_address)},
++ {0}
++ },
++ .options_prefix = "ao-spatial",
++};
+diff --git a/audio/out/buffer.c b/audio/out/buffer.c
+index 277b524..a7e38f9 100644
+--- a/audio/out/buffer.c
++++ b/audio/out/buffer.c
+@@ -685,8 +685,11 @@ static bool ao_play_data(struct ao *ao)
+ MP_TRACE(ao, "in=%d space=%d(%d) pl=%d, eof=%d\n",
+ samples, space, state.free_samples, p->playing, got_eof);
+
+- if (got_eof)
++ if (got_eof) {
++ if (ao->driver->end_of_stream)
++ ao->driver->end_of_stream(ao);
+ goto eof;
++ }
+
+ return samples > 0 && (samples < space || ao->untimed);
+
+diff --git a/audio/out/internal.h b/audio/out/internal.h
+index 85fa777..7b64ea3 100644
+--- a/audio/out/internal.h
++++ b/audio/out/internal.h
+@@ -173,6 +173,13 @@ struct ao_driver {
+ bool (*write)(struct ao *ao, void **data, int samples);
+ // push based: return mandatory stream information
+ void (*get_state)(struct ao *ao, struct mp_pcm_state *state);
++ // push based, optional: the audio has ended, and nothing more is written
++ // until reset(). For drivers that hold audio back until they get more.
++ void (*end_of_stream)(struct ao *ao);
++
++ // optional: mpv's software volume changed (ao_set_gain()). For drivers
++ // whose output it never reaches, such as a bitstream the host renders.
++ void (*set_gain)(struct ao *ao, float gain);
+
+ // Return the list of devices currently available in the system. Use
+ // ao_device_list_add() to add entries. The selected device will be set as
+diff --git a/include/mpv/spatial_audio.h b/include/mpv/spatial_audio.h
+new file mode 100644
+index 0000000..6733096
+--- /dev/null
++++ b/include/mpv/spatial_audio.h
+@@ -0,0 +1,105 @@
++/* Copyright (C) 2026 the mpv developers
++ *
++ * Permission to use, copy, modify, and/or distribute this software for any
++ * purpose with or without fee is hereby granted, provided that the above
++ * copyright notice and this permission notice appear in all copies.
++ *
++ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
++ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
++ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
++ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
++ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
++ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
++ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
++ */
++
++#ifndef MPV_CLIENT_API_SPATIAL_AUDIO_H_
++#define MPV_CLIENT_API_SPATIAL_AUDIO_H_
++
++#include
++#include
++#include
++
++#ifdef __cplusplus
++extern "C" {
++#endif
++
++/**
++ * The "spatial" audio output hands TrueHD and DTS-HD passthrough to the host
++ * application, which renders it itself, for example as Apple spatial audio
++ * through an AVSampleBufferAudioRenderer. Apple platforms only.
++ *
++ * Use it with --audio-spdif=truehd,dts-hd and --ao=spatial, (or
++ * --ao=spatial, to autoprobe the rest), and
++ * pass the address of a filled-in mpv_spatial_audio_sink, which must outlive
++ * the player, as the int64 option --ao-spatial-sink. The output refuses every
++ * other format, so PCM goes to the next output in the list.
++ *
++ * Load each file with loadfile replace: gapless handoff across files with a
++ * queued next file is not supported.
++ *
++ * mpv keeps its clock on an AVSampleBufferRenderSynchronizer that the output
++ * owns and the host renders into. It holds the clock until is_ready() returns
++ * true, then runs it from the timeline's start, so video waits for the host.
++ */
++
++#define MPV_SPATIAL_AUDIO_SINK_VERSION 2
++
++typedef enum mpv_spatial_audio_codec {
++ MPV_SPATIAL_AUDIO_CODEC_TRUEHD = 1,
++ MPV_SPATIAL_AUDIO_CODEC_DTSHD = 2,
++} mpv_spatial_audio_codec;
++
++/**
++ * Callbacks from the output. They are never called concurrently, except
++ * set_volume, which mpv calls from its core thread. Times are on the
++ * synchronizer's clock, in nanoseconds.
++ */
++typedef struct mpv_spatial_audio_sink {
++ /** MPV_SPATIAL_AUDIO_SINK_VERSION. */
++ int version;
++ void *ctx;
++ /**
++ * Prepares to render `codec` into `synchronizer`, an
++ * AVSampleBufferRenderSynchronizer that stays valid until close(). Return
++ * false to refuse; mpv then decodes the track to PCM.
++ */
++ bool (*open)(void *ctx, mpv_spatial_audio_codec codec, void *synchronizer);
++ /** Starts a new timeline at `time`, dropping everything queued. */
++ void (*begin)(void *ctx, int64_t time);
++ /**
++ * IEC 61937 bursts exactly as mpv's passthrough produces them, split
++ * anywhere, continuing the timeline from begin().
++ */
++ void (*write)(void *ctx, const void *data, size_t size);
++ /** Whether enough audio is queued to start the clock. */
++ bool (*is_ready)(void *ctx);
++ /**
++ * Whether the host failed, for example on a stream it cannot render. The
++ * output then reopens without the host for that codec, so mpv decodes the
++ * track to PCM. Once that PCM output has opened, the next track or audio
++ * reinitialization offers the codec to the host again.
++ */
++ bool (*has_failed)(void *ctx);
++ /** Releases what open() prepared. */
++ void (*close)(void *ctx);
++ /**
++ * Nothing more is written until the next begin(): render everything
++ * written, including what a startup buffer holds back, and become ready.
++ */
++ void (*end)(void *ctx);
++ /**
++ * mpv's software volume as a linear gain: --volume, --volume-gain,
++ * replaygain and --mute together, 0 when muted. mpv cannot scale a
++ * bitstream, so the host applies it to what it renders. Called after
++ * open() and whenever it changes; above 1 is amplification the host may
++ * clamp.
++ */
++ void (*set_volume)(void *ctx, float volume);
++} mpv_spatial_audio_sink;
++
++#ifdef __cplusplus
++}
++#endif
++
++#endif
+diff --git a/meson.build b/meson.build
+index 56cb290..c2d7a81 100644
+--- a/meson.build
++++ b/meson.build
+@@ -821,6 +821,7 @@ features += {'avfoundation': avfoundation.found()}
+ if features['avfoundation']
+ dependencies += avfoundation
+ sources += files('audio/out/ao_avfoundation.m')
++ sources += files('audio/out/ao_spatial.m')
+ endif
+
+ vo_avfoundation = dependency('appleframeworks', modules: ['AVFoundation', 'CoreMedia', 'CoreVideo'],
+@@ -1815,7 +1816,8 @@ if get_option('libmpv')
+ description: 'mpv media player client library')
+
+ headers = ['include/mpv/client.h', 'include/mpv/render.h',
+- 'include/mpv/render_gl.h', 'include/mpv/stream_cb.h']
++ 'include/mpv/render_gl.h', 'include/mpv/stream_cb.h',
++ 'include/mpv/spatial_audio.h']
+ install_headers(headers, subdir: 'mpv')
+
+ # Allow projects to build with libmpv by cloning into ./subprojects/mpv
diff --git a/Sources/BuildScripts/patch/libmpv/0008-player-drop-the-frame-held-for-the-old-ao-on-reload.patch b/Sources/BuildScripts/patch/libmpv/0008-player-drop-the-frame-held-for-the-old-ao-on-reload.patch
new file mode 100644
index 00000000..2b9e4ee4
--- /dev/null
+++ b/Sources/BuildScripts/patch/libmpv/0008-player-drop-the-frame-held-for-the-old-ao-on-reload.patch
@@ -0,0 +1,16 @@
+diff --git a/player/audio.c b/player/audio.c
+index b3e975d..c7e5650 100644
+--- a/player/audio.c
++++ b/player/audio.c
+@@ -776,6 +776,11 @@ void reload_audio_output(struct MPContext *mpctx)
+ if (ao_c) {
+ reset_audio_state(mpctx);
+ mp_output_chain_reset_harder(ao_c->filter);
++ // A frame the chain already handed over for the old AO would stay in
++ // the ao filter's input. Without a queue, ao_process() only peeks at
++ // it, so no request reaches the chain and the next AO never opens.
++ mp_filter_reset(ao_c->ao_filter);
++ mp_filter_wakeup(ao_c->ao_filter);
+ }
+
+ // Whether we can use spdif might have changed. If we failed to use spdif
diff --git a/Sources/BuildScripts/patch/libmpv/0009-f-output-chain-wake-after-reset-harder.patch b/Sources/BuildScripts/patch/libmpv/0009-f-output-chain-wake-after-reset-harder.patch
new file mode 100644
index 00000000..b1bfa013
--- /dev/null
+++ b/Sources/BuildScripts/patch/libmpv/0009-f-output-chain-wake-after-reset-harder.patch
@@ -0,0 +1,14 @@
+diff --git a/filters/f_output_chain.c b/filters/f_output_chain.c
+index 43ec3db..4bbbdde 100644
+--- a/filters/f_output_chain.c
++++ b/filters/f_output_chain.c
+@@ -339,6 +339,9 @@ void mp_output_chain_reset_harder(struct mp_output_chain *c)
+ p->ao = NULL;
+ mp_autoconvert_clear(p->convert);
+ }
++
++ // Re-propagate the still-asserted downstream request.
++ mp_filter_wakeup(p->f);
+ }
+
+ static void output_chain_destroy(struct mp_filter *f)
diff --git a/scripts/spatial-audio-output-test.m b/scripts/spatial-audio-output-test.m
new file mode 100644
index 00000000..fde9824f
--- /dev/null
+++ b/scripts/spatial-audio-output-test.m
@@ -0,0 +1,310 @@
+// Unit test for the "spatial" audio output (patch 0007). It drives the driver
+// the way mpv's push thread does, against a real synchronizer and a fake host
+// sink. Run scripts/test-spatial-audio-output.sh, which builds it against a
+// patched mpv checkout.
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "audio/out/ao_spatial.m"
+
+#import
+
+// The parts of mpv the driver calls.
+static atomic_int wakeups;
+static int reloads;
+void mp_msg(struct mp_log *log, int lev, const char *format, ...) {}
+void ao_wakeup(struct ao *ao) { atomic_fetch_add(&wakeups, 1); }
+void ao_request_reload(struct ao *ao) { reloads++; }
+bool af_fmt_is_spdif(int format) { return format >= AF_FORMAT_S_AAC && format < AF_FORMAT_COUNT; }
+const char *af_fmt_to_str(int format) { return "spdif"; }
+const m_option_type_t m_option_type_int64;
+
+struct fake_sink {
+ mpv_spatial_audio_sink sink;
+ bool refuse, ready, failed;
+ mpv_spatial_audio_codec codec;
+ AVSampleBufferRenderSynchronizer *synchronizer;
+ int begins, closes, ends;
+ int volumes;
+ float volume;
+ int64_t begin_time;
+ NSMutableData *received;
+};
+
+static bool sink_open(void *ctx, mpv_spatial_audio_codec codec, void *synchronizer)
+{
+ struct fake_sink *s = ctx;
+ s->codec = codec;
+ s->synchronizer = synchronizer;
+ return !s->refuse;
+}
+static void sink_begin(void *ctx, int64_t time)
+{
+ struct fake_sink *s = ctx;
+ s->begins++;
+ s->begin_time = time;
+ [s->received setLength:0];
+}
+static void sink_write(void *ctx, const void *data, size_t size)
+{
+ [((struct fake_sink *)ctx)->received appendBytes:data length:size];
+}
+static bool sink_is_ready(void *ctx) { return ((struct fake_sink *)ctx)->ready; }
+static bool sink_has_failed(void *ctx) { return ((struct fake_sink *)ctx)->failed; }
+static void sink_close(void *ctx) { ((struct fake_sink *)ctx)->closes++; }
+static void sink_set_volume(void *ctx, float volume)
+{
+ struct fake_sink *s = ctx;
+ s->volumes++;
+ s->volume = volume;
+}
+// The real host flushes its renderer here, then reports ready.
+static void sink_end(void *ctx)
+{
+ struct fake_sink *s = ctx;
+ s->ends++;
+ s->ready = true;
+}
+
+static int failures;
+#define CHECK(cond, ...) do { \
+ if (!(cond)) { failures++; printf("FAIL %s:%d: ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \
+} while (0)
+
+static struct fake_sink *new_sink(void)
+{
+ struct fake_sink *s = calloc(1, sizeof(*s));
+ s->sink = (mpv_spatial_audio_sink){
+ .version = MPV_SPATIAL_AUDIO_SINK_VERSION, .ctx = s,
+ .open = sink_open, .begin = sink_begin, .write = sink_write,
+ .is_ready = sink_is_ready, .has_failed = sink_has_failed, .close = sink_close,
+ .end = sink_end, .set_volume = sink_set_volume,
+ };
+ s->received = [[NSMutableData alloc] init];
+ return s;
+}
+
+// TrueHD passthrough as ad_spdif sets it up: 192 kHz, 8 channels of 16 bits.
+static struct ao *new_ao(struct fake_sink *s, int format)
+{
+ struct ao *ao = calloc(1, sizeof(*ao));
+ struct priv *p = calloc(1, sizeof(*p));
+ p->sink_address = s ? (int64_t)(intptr_t)&s->sink : 0;
+ ao->priv = p;
+ ao->format = format;
+ ao->samplerate = 192000;
+ ao->sstride = 16;
+ // As ao_alloc() does: ao_set_gain() reaches the driver before init().
+ atomic_store(&ao->gain, 1.0f);
+ audio_out_spatial.set_gain(ao, 1.0f);
+ return ao;
+}
+
+static struct mp_pcm_state state_of(struct ao *ao)
+{
+ struct mp_pcm_state state = {0};
+ get_state(ao, &state);
+ return state;
+}
+
+// One MAT burst lasts 20 ms: 3840 samples at 192 kHz.
+static void write_burst(struct ao *ao, uint8_t fill)
+{
+ uint8_t burst[3840 * 16];
+ memset(burst, fill, sizeof(burst));
+ void *planes[] = {burst};
+ audio_write(ao, planes, 3840);
+}
+
+int main(void)
+{
+ @autoreleasepool {
+ // Without a sink, or for PCM, the output refuses, so mpv uses the next one.
+ struct ao *none = new_ao(NULL, AF_FORMAT_S_TRUEHD);
+ CHECK(init(none) < 0, "init without a sink must fail");
+
+ struct fake_sink *s = new_sink();
+ // ao_alloc() sets the gain before init(); nothing is open to tell.
+ struct ao *unopened = new_ao(s, AF_FORMAT_S_TRUEHD);
+ audio_out_spatial.set_gain(unopened, 1.0f);
+ CHECK(s->volumes == 0, "a gain set before init() reaches no host");
+ struct ao *pcm = new_ao(s, AF_FORMAT_S16);
+ CHECK(init(pcm) < 0, "init for PCM must fail");
+ CHECK(s->codec == 0, "open must not be called for PCM");
+
+ s->refuse = true;
+ struct ao *refused = new_ao(s, AF_FORMAT_S_TRUEHD);
+ CHECK(init(refused) < 0, "init must fail when the host refuses");
+ CHECK(s->codec == MPV_SPATIAL_AUDIO_CODEC_TRUEHD, "open gets the codec");
+ CHECK(s->volumes == 0, "a refused stream gets no volume");
+ s->refuse = false;
+
+ struct ao *dts = new_ao(s, AF_FORMAT_S_DTSHD);
+ CHECK(init(dts) == 0 && s->codec == MPV_SPATIAL_AUDIO_CODEC_DTSHD, "DTS-HD opens as DTS-HD");
+ uninit(dts);
+
+ // TrueHD: opened with the synchronizer the clock runs on, at rate 0.
+ struct ao *ao = new_ao(s, AF_FORMAT_S_TRUEHD);
+ struct priv *p = ao->priv;
+ s->begins = 0;
+ s->volumes = 0;
+ CHECK(init(ao) == 0, "init for TrueHD succeeds");
+ CHECK(s->synchronizer == p->synchronizer && s->synchronizer != nil, "open gets the synchronizer");
+ CHECK(s->begins == 1, "init begins a timeline");
+ CHECK(ao->device_buffer == 192000 * 3, "mpv may queue 3 s");
+ CHECK(p->synchronizer.rate == 0, "the clock waits at rate 0");
+
+ // mpv cannot scale a bitstream, so its volume and mute go to the host
+ // as mpv sets them (ao_set_gain() calls the driver), paused or not.
+ CHECK(s->volumes == 1 && s->volume == 1.0f, "open hands the host mpv's volume");
+ set_pause(ao, true);
+ audio_out_spatial.set_gain(ao, 0.0f);
+ CHECK(s->volumes == 2 && s->volume == 0.0f, "a change reaches the host at once, paused too, mute as 0");
+ state_of(ao);
+ CHECK(s->volumes == 2, "reading the state sends nothing more");
+ set_pause(ao, false);
+ audio_out_spatial.set_gain(ao, 1.0f);
+
+ // Bursts reach the host unchanged, before and after start().
+ write_burst(ao, 0x5a);
+ start(ao);
+ for (int n = 0; n < 9; n++)
+ write_burst(ao, (uint8_t)n);
+ CHECK(s->received.length == 10 * 3840 * 16, "every byte reaches the host (%lu)", (unsigned long)s->received.length);
+ CHECK(((const uint8_t *)s->received.bytes)[0] == 0x5a, "bytes arrive unchanged");
+
+ // While the host is not ready, the clock holds and everything counts as queued.
+ struct mp_pcm_state st = state_of(ao);
+ CHECK(st.playing, "reports playing while the clock waits, or mpv sees an underrun");
+ CHECK(fabs(st.delay - 0.2) < 0.001, "delay is all written audio while waiting (%f)", st.delay);
+ CHECK(st.free_samples == 192000 * 3 - 38400, "free space is the rest (%d)", st.free_samples);
+ int before = atomic_load(&wakeups);
+ usleep(60 * 1000);
+ CHECK(fabs(state_of(ao).delay - 0.2) < 0.001, "the clock does not move while waiting");
+ CHECK(atomic_load(&wakeups) > before, "the ao thread is nudged while waiting");
+
+ // Once ready, the clock runs from the timeline's start. Sleeps on a
+ // busy machine overshoot, so the delay is checked against the time
+ // that actually passed.
+ s->ready = true;
+ st = state_of(ao);
+ CFTimeInterval started = CACurrentMediaTime();
+ CHECK(p->synchronizer.rate == 1, "the clock starts when the host is ready");
+ usleep(50 * 1000);
+ double delay = state_of(ao).delay;
+ double expected = MPMAX(0.2 - (CACurrentMediaTime() - started), 0);
+ CHECK(fabs(delay - expected) < 0.02, "delay falls in real time (%f, expected %f)", delay, expected);
+ usleep(15 * 1000); // lets a timer tick already in flight land
+ before = atomic_load(&wakeups);
+ usleep(40 * 1000);
+ CHECK(atomic_load(&wakeups) == before, "no nudging once the clock runs");
+
+ // Pause stops the clock, resume restarts it.
+ set_pause(ao, true);
+ CHECK(p->synchronizer.rate == 0, "pause stops the clock");
+ delay = state_of(ao).delay;
+ usleep(50 * 1000);
+ CHECK(fabs(state_of(ao).delay - delay) < 0.002, "the clock holds while paused");
+ set_pause(ao, false);
+ CHECK(p->synchronizer.rate == 1, "resume restarts the clock");
+
+ // Playing out everything ends the stream: mpv reads that as EOF.
+ usleep(150 * 1000);
+ st = state_of(ao);
+ CHECK(!st.playing && st.delay == 0, "all audio played ends playback (%f)", st.delay);
+
+ // Audio after an underrun would land in the past on the old timeline,
+ // so it starts a new one, which waits for the host again.
+ int begins = s->begins;
+ s->ready = false;
+ write_burst(ao, 2);
+ CHECK(s->begins == begins + 1, "a write after an underrun begins a new timeline");
+ CHECK(!p->clock_running && p->synchronizer.rate == 0, "the new timeline waits for the host");
+ // mpv may not have seen the underrun, and then calls no start().
+ CHECK(p->playing && atomic_load(&p->waiting), "playback stays active across the new timeline");
+ CHECK(fabs(state_of(ao).delay - 0.02) < 0.001, "only the new audio is queued");
+
+ // A seek begins a new timeline at the current time and waits for the host again.
+ begins = s->begins;
+ reset(ao);
+ CHECK(s->begins == begins + 1 && s->received.length == 0, "reset begins a new timeline");
+ CHECK(p->synchronizer.rate == 0 && !p->clock_running, "reset stops the clock");
+ CHECK(s->begin_time == ns_from_time(CMTimebaseGetTime(p->synchronizer.timebase)), "the timeline starts at the clock's time");
+ for (int i = 0; i < 3; i++)
+ write_burst(ao, 1);
+ start(ao);
+ CHECK(fabs(state_of(ao).delay - 0.06) < 0.001, "delay restarts from the new timeline");
+
+ // At the end the host lets go of what it holds back, so audio shorter
+ // than its startup buffer still plays.
+ end_of_stream(ao);
+ CHECK(s->ends == 1, "the host hears the end");
+ state_of(ao);
+ CHECK(p->clock_running && p->synchronizer.rate == 1, "the clock starts once the host has flushed");
+ CHECK(state_of(ao).free_samples == 0, "the next file waits until this one has played out");
+ set_pause(ao, true);
+ state_of(ao);
+ CHECK(!atomic_load(&p->waiting), "no nudging while paused at the end, as nothing plays out");
+ set_pause(ao, false);
+ CHECK(state_of(ao).delay > 0 && atomic_load(&p->waiting), "nudging resumes with playback");
+ int nudged = atomic_load(&wakeups);
+ usleep(100 * 1000);
+ CHECK(atomic_load(&wakeups) > nudged, "the ao thread is nudged until the ended timeline has played out");
+ state_of(ao);
+ CHECK(!atomic_load(&p->waiting), "and no longer once it has");
+ begins = s->begins;
+ write_burst(ao, 3);
+ CHECK(s->begins == begins + 1 && !p->ended, "the next file begins a new timeline");
+ CHECK(p->playing, "and keeps playing, as mpv sends no start() between gapless files");
+
+ // A long run stays representable: 14 h at 192 kHz overflows written * 1e9.
+ reset(ao);
+ CMTime now = CMTimebaseGetTime(p->synchronizer.timebase);
+ p->timeline = CMTimeSubtract(now, CMTimeMake(14 * 3600, 1));
+ p->written = INT64_C(192000) * 14 * 3600 + 19200; // and 0.1 s still to play
+ CHECK(fabs(state_of(ao).delay - 0.1) < 0.001, "a 14 h timeline keeps its delay (%f)", state_of(ao).delay);
+
+ // A host failure asks mpv to reopen the output, once.
+ s->failed = true;
+ state_of(ao);
+ state_of(ao);
+ CHECK(reloads == 1, "a failure requests one reload (%d)", reloads);
+
+ uninit(ao);
+ CHECK(s->closes == 2, "uninit closes the sink (%d)", s->closes);
+ before = atomic_load(&wakeups);
+ usleep(30 * 1000);
+ CHECK(atomic_load(&wakeups) == before, "no wakeups after uninit");
+
+ // The reopen refuses the failed format without asking the host, so
+ // mpv falls back to decoding it.
+ s->failed = false;
+ s->codec = 0;
+ struct ao *reopened = new_ao(s, AF_FORMAT_S_TRUEHD);
+ CHECK(init(reopened) < 0, "the reopen after a failure refuses TrueHD");
+ CHECK(s->codec == 0, "and does not offer it to the host");
+ struct ao *other = new_ao(s, AF_FORMAT_S_DTSHD);
+ CHECK(init(other) == 0, "DTS-HD is not latched by a TrueHD failure");
+ uninit(other);
+ struct ao *ac3 = new_ao(s, AF_FORMAT_S_AC3);
+ CHECK(init(ac3) < 0, "AC-3 goes to the next output");
+ s->codec = 0;
+ CHECK(init(new_ao(s, AF_FORMAT_S_TRUEHD)) < 0, "another passthrough format leaves the latch");
+ CHECK(s->codec == 0, "and the host still is not asked");
+
+ // Once the decoded track opens a PCM output, the next track tries the
+ // host again.
+ CHECK(init(new_ao(s, AF_FORMAT_FLOAT)) < 0, "PCM goes to the next output");
+ struct ao *retried = new_ao(s, AF_FORMAT_S_TRUEHD);
+ CHECK(init(retried) == 0 && s->codec == MPV_SPATIAL_AUDIO_CODEC_TRUEHD,
+ "a PCM initialization clears the latch");
+ uninit(retried);
+ }
+ printf(failures ? "%d FAILED\n" : "all passed\n", failures);
+ return failures ? 1 : 0;
+}
diff --git a/scripts/test-spatial-audio-output.sh b/scripts/test-spatial-audio-output.sh
new file mode 100755
index 00000000..ca12f4be
--- /dev/null
+++ b/scripts/test-spatial-audio-output.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Builds and runs the "spatial" audio output's unit test (patch 0007) against
+# the pinned mpv with this repository's patches applied. macOS only.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VERSION=$(sed -n '/case .libmpv:/{n;s/.*"\(.*\)".*/\1/p;q;}' "$ROOT/Sources/BuildScripts/XCFrameworkBuild/main.swift")
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+git -c advice.detachedHead=false clone -q --depth 1 --branch "$VERSION" \
+ https://github.com/mpv-player/mpv.git "$WORK/mpv"
+for patch in "$ROOT"/Sources/BuildScripts/patch/libmpv/*.patch; do
+ git -C "$WORK/mpv" apply "$patch"
+done
+
+# The driver reads one build switch; the test supplies the mpv functions it calls.
+printf '#pragma once\n#define HAVE_AVFOUNDATION 1\n' > "$WORK/config.h"
+xcrun --sdk macosx clang -x objective-c -std=c11 -Wall -Wno-unused-parameter \
+ -I"$WORK" -I"$WORK/mpv" -I"$WORK/mpv/include" \
+ "$ROOT/scripts/spatial-audio-output-test.m" \
+ -framework AVFoundation -framework CoreMedia -framework Foundation -framework QuartzCore \
+ -o "$WORK/spatial-audio-output-test"
+"$WORK/spatial-audio-output-test"