Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Sources/AetherEngine/Audio/AudioPlaybackHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -216,6 +217,7 @@ final class AudioPlaybackHost {
}

var clockRateForTesting: Float? { audioOutput?.rate }
var outputVolumeForTesting: Float? { audioOutput?.volume }
#endif

func pause() {
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions Sources/AetherEngine/Native/SoftwarePlaybackHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions Tests/AetherEngineTests/Issue660SoftwareVolumeTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading