From 3c0530fa5634a8be524a96d5e6b4658fc511d604 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Sun, 27 Sep 2026 21:38:27 +0200 Subject: [PATCH] fix(audio): a software host keeps a volume set before its audio output exists (AE#660) The engine hands its stored volume to a freshly built host right away, before load() runs. SoftwarePlaybackHost and AudioPlaybackHost only forwarded it to their AudioOutput, which load() builds later, so the write was dropped and every software session started at full volume. The getter also reported 1.0 in that window. Both hosts now hold the volume themselves and hand it to each output they build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Mf4MXgM9bqugHoe5TW4BYf --- .../Audio/AudioPlaybackHost.swift | 8 +-- .../Native/SoftwarePlaybackHost.swift | 7 +-- .../Issue660SoftwareVolumeTests.swift | 53 +++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 Tests/AetherEngineTests/Issue660SoftwareVolumeTests.swift diff --git a/Sources/AetherEngine/Audio/AudioPlaybackHost.swift b/Sources/AetherEngine/Audio/AudioPlaybackHost.swift index cff1548f..1cb47cc6 100644 --- a/Sources/AetherEngine/Audio/AudioPlaybackHost.swift +++ b/Sources/AetherEngine/Audio/AudioPlaybackHost.swift @@ -145,6 +145,7 @@ final class AudioPlaybackHost { self.audioDecoder = aDec self.audioStreamIndex = resolvedAudioIdx self.audioOutput = AudioOutput() + self.audioOutput?.volume = volume if let start = startPosition, start > 0 { // #254: same off-main, deadline-bounded reposition the transport seek uses. Also load()'s @@ -216,6 +217,7 @@ final class AudioPlaybackHost { } var clockRateForTesting: Float? { audioOutput?.rate } + var outputVolumeForTesting: Float? { audioOutput?.volume } #endif func pause() { @@ -320,9 +322,9 @@ final class AudioPlaybackHost { isReady = false } - var volume: Float { - get { audioOutput?.volume ?? 1.0 } - set { audioOutput?.volume = newValue } + /// #660: held here, not only on the output, because the engine sets it before `load()` builds one. + var volume: Float = 1.0 { + didSet { audioOutput?.volume = volume } } // MARK: - Demux loop diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift index 0fb46039..1a64784d 100644 --- a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift +++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift @@ -933,6 +933,7 @@ final class SoftwarePlaybackHost { // AudioOutput owns the AVSampleBufferRenderSynchronizer (master clock). Created unconditionally: video-only previously got no clock (frozen frame, currentTime=0). Layer attached in play() after the engine hangs it in the view hierarchy (attaching free-floating fails FigVideoQueueRemote -12080 on tvOS 26+). self.audioOutput = AudioOutput() + self.audioOutput?.volume = volume self.audioOutput?.setPresentationOffset(seconds: audioDelaySeconds) // AE#464 // Reset the live feeder state for the new session. @@ -1620,9 +1621,9 @@ final class SoftwarePlaybackHost { isVideoReadyForDisplay = false } - var volume: Float { - get { audioOutput?.volume ?? 1.0 } - set { audioOutput?.volume = newValue } + /// #660: held here, not only on the output, because the engine sets it before `load()` builds one. + var volume: Float = 1.0 { + didSet { audioOutput?.volume = volume } } // MARK: - Demux loop diff --git a/Tests/AetherEngineTests/Issue660SoftwareVolumeTests.swift b/Tests/AetherEngineTests/Issue660SoftwareVolumeTests.swift new file mode 100644 index 00000000..ab1c8045 --- /dev/null +++ b/Tests/AetherEngineTests/Issue660SoftwareVolumeTests.swift @@ -0,0 +1,53 @@ +import Testing +import Foundation +@testable import AetherEngine + +/// #660: the engine hands its stored volume to a host right after building it, before `load()`. +/// The FFmpeg hosts forwarded that write to an `AudioOutput` that `load()` had not built yet, so it +/// was dropped and every software session started at full volume. The host now keeps the volume +/// itself and hands it to each output it builds. +@Suite("Software hosts keep a volume set before their audio output exists (#660)") +struct Issue660SoftwareVolumeTests { + + @MainActor + @Test("a software host reports the volume it was given before load") + func softwareHostKeepsVolumeBeforeLoad() { + let host = SoftwarePlaybackHost() + host.volume = 0.25 + #expect(host.volume == 0.25) + } + + @MainActor + @Test("an audio host reports the volume it was given before load") + func audioHostKeepsVolumeBeforeLoad() { + let host = AudioPlaybackHost() + host.volume = 0.25 + #expect(host.volume == 0.25) + } + + @MainActor + @Test("an audio host plays at the volume it was given before load") + func audioHostAppliesVolumeOnLoad() async throws { + let host = AudioPlaybackHost() + host.volume = 0.25 + let demuxer = Demuxer() + try demuxer.open(reader: DataIOReader(data: makeWAV(seconds: 1))) + try await host.load(demuxer: demuxer, startPosition: nil, audioSourceStreamIndex: nil) + defer { host.stop() } + #expect(host.outputVolumeForTesting == 0.25) + } + + private func makeWAV(seconds: Double) -> Data { + let sampleRate = 48_000, channels = 2 + let pcm = Data(count: Int(Double(sampleRate) * seconds) * channels * 2) + var d = Data() + func str(_ s: String) { d.append(s.data(using: .ascii)!) } + func u32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } + func u16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } + str("RIFF"); u32(UInt32(36 + pcm.count)); str("WAVE") + str("fmt "); u32(16); u16(1); u16(UInt16(channels)); u32(UInt32(sampleRate)) + u32(UInt32(sampleRate * channels * 2)); u16(UInt16(channels * 2)); u16(16) + str("data"); u32(UInt32(pcm.count)); d.append(pcm) + return d + } +}