Apply volume and mute on the avfoundation renderer directly

mpv's volume/mute properties are software volume applied before samples
reach the AO, and AVSampleBufferAudioRenderer queues ~2s ahead, so slider
and mute changes were heard only after the queue drained. With the
avfoundation AO active, drive ao-volume/ao-mute (renderer volume, instant)
and keep software volume neutral; other AOs keep software volume. Re-apply
on every audio reconfig since a new renderer starts at full volume.
This commit is contained in:
Arkadiusz Fal
2026-10-10 23:58:03 +02:00
parent 22e94e4f6c
commit cf1acb1986
2 changed files with 55 additions and 14 deletions

View File

@@ -628,8 +628,6 @@ final class MPVClient: @unchecked Sendable {
observeProperty("demuxer-cache-time", format: MPV_FORMAT_DOUBLE)
observeProperty("demuxer-cache-state", format: MPV_FORMAT_NODE)
observeProperty("speed", format: MPV_FORMAT_DOUBLE)
observeProperty("volume", format: MPV_FORMAT_DOUBLE)
observeProperty("mute", format: MPV_FORMAT_FLAG)
observeProperty("core-idle", format: MPV_FORMAT_FLAG)
observeProperty("seeking", format: MPV_FORMAT_FLAG)
// Cache-related properties for debugging playback start issues
@@ -891,6 +889,54 @@ final class MPVClient: @unchecked Sendable {
}
}
/// Desired volume (0–100) and mute state; only touched on mpvQueue.
private var desiredVolume: Double = 100
private var desiredMute = false
/// Set the playback volume (0–100) and mute state.
///
/// mpv's `volume`/`mute` properties are software volume applied before
/// samples reach the AO, so with avfoundation (AVSampleBufferAudioRenderer
/// queues ~2s ahead) changes are heard only after the queue drains. When
/// avfoundation is active, drive the renderer via `ao-volume`/`ao-mute`
/// instead (instant) and keep software volume neutral. Other AOs
/// (audiounit/coreaudio) keep using software volume.
func setVolume(_ volume: Double, muted: Bool) {
mpvQueue.async { [weak self] in
guard let self else { return }
self.desiredVolume = volume
self.desiredMute = muted
self.applyVolumeUnsafe()
}
}
/// Must be called on mpvQueue. Re-run on every audio reconfig, since a new
/// AO instance (new renderer) starts at full volume.
private func applyVolumeUnsafe() {
guard let mpv, !isDestroyed else { return }
var currentAo: String?
if let cString = mpv_get_property_string(mpv, "current-ao") {
currentAo = String(cString: cString)
mpv_free(cString)
}
var softVolume = desiredVolume
var softMute: Int32 = desiredMute ? 1 : 0
if currentAo == "avfoundation" {
var aoVolume = desiredVolume
var aoMute: Int32 = desiredMute ? 1 : 0
let volumeResult = mpv_set_property(mpv, "ao-volume", MPV_FORMAT_DOUBLE, &aoVolume)
let muteResult = mpv_set_property(mpv, "ao-mute", MPV_FORMAT_FLAG, &aoMute)
if volumeResult >= 0 && muteResult >= 0 {
softVolume = 100
softMute = 0
}
}
mpv_set_property(mpv, "volume", MPV_FORMAT_DOUBLE, &softVolume)
mpv_set_property(mpv, "mute", MPV_FORMAT_FLAG, &softMute)
}
/// Internal property setter (must be called on mpvQueue).
private func setPropertyUnsafe(_ name: String, _ value: Any) {
guard let mpv, !isDestroyed else { return }
@@ -1452,6 +1498,11 @@ final class MPVClient: @unchecked Sendable {
}
}
case MPV_EVENT_AUDIO_RECONFIG:
mpvQueue.async { [weak self] in
self?.applyVolumeUnsafe()
}
case MPV_EVENT_LOG_MESSAGE:
if let data = event.data {
let msg = data.assumingMemoryBound(to: mpv_event_log_message.self).pointee

View File

@@ -48,7 +48,7 @@ final class MPVBackend: PlayerBackend {
get { _volume }
set {
_volume = newValue
mpvClient?.setProperty("volume", Double(newValue * 100))
mpvClient?.setVolume(Double(newValue * 100), muted: _isMuted)
}
}
@@ -56,7 +56,7 @@ final class MPVBackend: PlayerBackend {
get { _isMuted }
set {
_isMuted = newValue
mpvClient?.setProperty("mute", newValue)
mpvClient?.setVolume(Double(_volume * 100), muted: newValue)
}
}
@@ -1566,16 +1566,6 @@ extension MPVBackend: MPVClientDelegate {
_rate = Float(speed)
}
case "volume":
if let vol = value as? Double {
_volume = Float(vol / 100)
}
case "mute":
if let muted = value as? Bool {
_isMuted = muted
}
case "core-idle":
// core-idle indicates MPV is processing, but doesn't mean frames are ready
break