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 + } +}