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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ the public-API contract.

### Changed

- Software packet-cache audio coverage joins timestamp quantization only when admitted codec sample geometry supports one continuous rational sample clock. Unknown layouts, real gaps and accumulating drift remain strict; duration-less audio keeps its successor coverage.

- **The documentation no longer says TrueHD Atmos objects are always lost.** The Known limitations
entry in `docs/formats.md`, the `AudioDelivery.bridged` reference and the channel bridge's notes
predate `objectAudioRendering` (#731) and said objects and heights never survive. They now say the
Expand Down
1 change: 1 addition & 0 deletions Scripts/test-software-packet-read-ahead.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ trap 'rm -f "$TASK_TMP/check"; rmdir "$TASK_TMP"' EXIT
xcrun swiftc \
"$TASK_ROOT/Sources/AetherEngine/Native/SoftwareStoredPacket.swift" \
"$TASK_ROOT/Sources/AetherEngine/Native/SoftwarePacketCoverage.swift" \
"$TASK_ROOT/Sources/AetherEngine/Native/SoftwareAudioPacketCoverage.swift" \
"$TASK_ROOT/Sources/AetherEngine/Native/SoftwareVideoPacketCoverage.swift" \
"$TASK_ROOT/Sources/AetherEngine/Native/SoftwarePacketDiskFIFO.swift" \
"$TASK_ROOT/Sources/AetherEngine/Native/SoftwarePacketStore.swift" \
Expand Down
115 changes: 115 additions & 0 deletions Sources/AetherEngine/Native/SoftwareAudioPacketCoverage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Foundation

/// Exact decoded sample geometry, admitted by the host from codec/stream metadata. Never bitrate.
/// Unknown, reservoir-dependent and variable-frame codecs retain strict packet coverage.
struct SoftwareAudioPacketTiming: Sendable {
enum Layout: Sendable {
case fixed(samples: Int32, packetBytes: Int?)
case pcm(bytesPerSampleFrame: Int)
}
let sampleRate: Int32
let layout: Layout

func samples(byteCount: Int) -> Int64? {
guard sampleRate > 0, sampleRate <= 768_000, byteCount > 0 else { return nil }
let result: Int64
switch layout {
case let .fixed(samples, packetBytes):
guard packetBytes == nil || packetBytes == byteCount else { return nil }
result = Int64(samples)
case let .pcm(bytesPerSampleFrame):
guard bytesPerSampleFrame > 0, byteCount % bytesPerSampleFrame == 0 else { return nil }
result = Int64(byteCount / bytesPerSampleFrame)
}
guard result > 0, result <= Int64(sampleRate) / 2 else { return nil }
return result
}
}

/// Audio coverage uses the same strict interval set as video/seek. Only a verified sample clock
/// may join quantized endpoints. Carrying the feasible fractional phase across packets prevents
/// accepting an extra tick on every packet (a fixed epsilon merge would silently accumulate it).
/// All state is O(1) besides the existing bounded interval set; no packet data or decoding is kept.
struct SoftwareAudioPacketCoverage: Sendable {
private var coverage: SoftwarePacketCoverage
private let numerator: Int32
private let denominator: Int32
private let timing: SoftwareAudioPacketTiming?
private var previousPTS: Int64?
private var previousSamples: Int64?
private var phaseLow: Int64 = 0
private var phaseHigh: Int64 = 0

init(timeBaseNumerator: Int32, timeBaseDenominator: Int32,
timing: SoftwareAudioPacketTiming? = nil, maximumRangeCount: Int = 4096) {
numerator = timeBaseNumerator
denominator = timeBaseDenominator
self.timing = timing
coverage = SoftwarePacketCoverage(maximumRangeCount: maximumRangeCount)
}

var isFull: Bool { coverage.isFull }
var rangeCount: Int { coverage.rangeCount }
func rangesSeconds(timeBaseNumerator: Int32, timeBaseDenominator: Int32) -> [ClosedRange<Double>] {
coverage.rangesSeconds(timeBaseNumerator: timeBaseNumerator, timeBaseDenominator: timeBaseDenominator)
}
func frontierSeconds(containing seconds: Double, timeBaseNumerator: Int32,
timeBaseDenominator: Int32) -> Double? {
coverage.frontierSeconds(containing: seconds, timeBaseNumerator: timeBaseNumerator,
timeBaseDenominator: timeBaseDenominator)
}
mutating func prune(before tick: Int64) { coverage.prune(before: tick) }
mutating func reset() { coverage.reset(); resetClock() }
private mutating func resetClock() {
previousPTS = nil; previousSamples = nil; phaseLow = 0; phaseHigh = 0
}

mutating func insert(pts: Int64, duration: Int64, byteCount: Int) {
guard pts != Int64.min, numerator > 0, denominator > 0 else { resetClock(); return }
let prior = previousPTS
let (delta, overflow) = pts.subtractingReportingOverflow(prior ?? pts)
let samples = verifiedSamples(duration: duration, byteCount: byteCount)
var joined = false
if !overflow, delta >= 0, let prior, let samplesBefore = previousSamples,
samples != nil, let timing {
let scale = Int64(timing.sampleRate) * Int64(numerator)
let (ticks, ticksOverflow) = delta.multipliedReportingOverflow(by: scale)
let (elapsed, elapsedOverflow) = samplesBefore.multipliedReportingOverflow(by: Int64(denominator))
let (shift, shiftOverflow) = elapsed.subtractingReportingOverflow(ticks)
let (low, lowOverflow) = phaseLow.addingReportingOverflow(shift)
let (high, highOverflow) = phaseHigh.addingReportingOverflow(shift)
if !ticksOverflow, !elapsedOverflow, !shiftOverflow, !lowOverflow, !highOverflow,
max(0, low) <= min(scale - 1, high) {
phaseLow = max(0, low); phaseHigh = min(scale - 1, high)
if delta > 0 { coverage.insert(pts: prior, duration: delta) }
joined = true
}
}
// Preserve upstream's duration-less audio contract (including repeated sub-ms TrueHD
// timestamps). A missing/backward/overflowing timestamp breaks the predecessor chain.
// This fallback does NOT apply to positive durations lacking trustworthy sample geometry.
if duration == 0, timing == nil, !overflow, delta > 0, let prior,
delta <= Int64(denominator) / (2 * Int64(numerator)) {
coverage.insert(pts: prior, duration: delta)
}
if duration > 0 { coverage.insert(pts: pts, duration: duration) }
if !joined {
phaseLow = 0
phaseHigh = timing.map { Int64($0.sampleRate) * Int64(numerator) - 1 } ?? 0
}
previousPTS = pts
previousSamples = samples
}

private func verifiedSamples(duration: Int64, byteCount: Int) -> Int64? {
guard duration >= 0, let timing, let samples = timing.samples(byteCount: byteCount) else { return nil }
let scale = Int64(timing.sampleRate) * Int64(numerator)
let (length, overflow) = samples.multipliedReportingOverflow(by: Int64(denominator))
guard !overflow, scale > 0 else { return nil }
let floor = length / scale
let ceil = floor + (length % scale == 0 ? 0 : 1)
// A declared duration conflicting with sample geometry is not quantization evidence.
guard duration == 0 || duration == floor || duration == ceil else { return nil }
return samples
}
}
35 changes: 6 additions & 29 deletions Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ final class SoftwarePacketReadAhead: @unchecked Sendable {
let index: Int32
let numerator: Int32
let denominator: Int32
var audioTiming: SoftwareAudioPacketTiming? = nil
}
struct Snapshot: Sendable {
let packetCount: Int
Expand Down Expand Up @@ -85,9 +86,7 @@ final class SoftwarePacketReadAhead: @unchecked Sendable {
private var storedVideoSeconds: Double?
private var consumedVideoSeconds: Double?
private var videoCoverage: SoftwarePacketCoverage
private var audioCoverage: SoftwarePacketCoverage
/// Newest audio timestamp stored this epoch, for duration-less packets. See `insertAudioCoverageLocked`.
private var lastAudioPTS: Int64?
private var audioCoverage: SoftwareAudioPacketCoverage
private var presentationCoverage: SoftwareVideoPacketCoverage?
private struct Keyframe {
let seconds: Double
Expand All @@ -111,7 +110,9 @@ final class SoftwarePacketReadAhead: @unchecked Sendable {
self.sourceClock = initialSourceClock
self.fifo = fifo
self.videoCoverage = SoftwarePacketCoverage(maximumRangeCount: coverageRangeCap)
self.audioCoverage = SoftwarePacketCoverage(maximumRangeCount: coverageRangeCap)
self.audioCoverage = SoftwareAudioPacketCoverage(
timeBaseNumerator: audio?.numerator ?? 1, timeBaseDenominator: audio?.denominator ?? 1,
timing: audio?.audioTiming, maximumRangeCount: coverageRangeCap)
self.presentationCoverage = videoReorderDepth.map {
SoftwareVideoPacketCoverage(timeBaseNumerator: video.numerator,
timeBaseDenominator: video.denominator, reorderDepth: $0,
Expand Down Expand Up @@ -480,7 +481,6 @@ final class SoftwarePacketReadAhead: @unchecked Sendable {
ended = false; failure = nil; failureOrigin = .none
videoCoverage.reset(); audioCoverage.reset(); presentationCoverage?.reset()
storedVideoSeconds = nil; consumedVideoSeconds = nil
lastAudioPTS = nil
keyframes.removeAll(keepingCapacity: true)
residency = SoftwarePacketResidency()
}
Expand Down Expand Up @@ -607,7 +607,7 @@ final class SoftwarePacketReadAhead: @unchecked Sendable {
if audioCoverage.isFull, let tick = playheadTick(audio) {
audioCoverage.prune(before: tick)
}
insertAudioCoverageLocked(pts: packet.pts, duration: packet.duration, stream: audio)
audioCoverage.insert(pts: packet.pts, duration: packet.duration, byteCount: packet.bytes.count)
}
}
condition.broadcast()
Expand All @@ -618,29 +618,6 @@ final class SoftwarePacketReadAhead: @unchecked Sendable {
} catch { recordFailure(error, token: token, origin: failureOrigin) }
}

/// Widest gap between two duration-less audio packets that still counts as contiguous. Real
/// frame spacing is 0.8 ms (TrueHD) to ~100 ms (long AAC/Vorbis frames); a larger gap is a hole.
static let maxAudioSuccessorGapSeconds = 0.5

/// Audio arrives in presentation order, so a packet without a duration still proves coverage up
/// to the next packet's timestamp. Matroska TrueHD is the case that needs it: no packet carries
/// a duration and the millisecond timestamps repeat (0.83 ms frames), so the duration model
/// rejected every packet, the A/V frontier stayed nil, and no seek ever hit the cache: each one,
/// even back to a spot just played, refetched from the source.
private func insertAudioCoverageLocked(pts: Int64, duration: Int64, stream: Stream) {
guard pts != Int64.min else { return }
let previous = lastAudioPTS
if previous.map({ pts > $0 }) ?? true { lastAudioPTS = pts }
if duration > 0 {
audioCoverage.insert(pts: pts, duration: duration)
return
}
guard let previous, pts > previous, stream.numerator > 0, stream.denominator > 0,
Double(pts - previous) * Double(stream.numerator) / Double(stream.denominator)
<= Self.maxAudioSuccessorGapSeconds else { return }
audioCoverage.insert(pts: previous, duration: pts - previous)
}

private func shouldParkLocked() -> Bool {
// One source record can cross the budget. A consumed active chunk cannot be deleted before
// cursor rollover, so residency also includes bounded protected chunk slack (not an
Expand Down
46 changes: 45 additions & 1 deletion Sources/AetherEngine/Native/SoftwarePlaybackHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,49 @@ final class SoftwarePlaybackHost {
}
}

/// Only codec sample geometry is admitted, never the WMA bitrate fallback in
/// av_get_audio_frame_duration2. Parameters are copied before the read-ahead thread starts.
/// Variable/packed or reservoir-dependent packet layouts stay strict until proven separately.
nonisolated static func audioPacketTiming(_ parameters: UnsafeMutablePointer<AVCodecParameters>)
-> SoftwareAudioPacketTiming? {
let p = parameters.pointee
guard p.sample_rate > 0, p.sample_rate <= 768_000 else { return nil }
let samples: Int32
switch p.codec_id {
case AV_CODEC_ID_AC3: samples = 1536
case AV_CODEC_ID_MP1: samples = 384
case AV_CODEC_ID_MP2: samples = 1152
case AV_CODEC_ID_MP3: samples = p.sample_rate <= 24000 ? 576 : 1152
case AV_CODEC_ID_AAC, AV_CODEC_ID_EAC3:
// Includes known AAC 960/1024/SBR geometry. A packet with a different declared
// duration fails the sample-clock admission instead of inheriting the initial size.
guard p.frame_size > 1 else { return nil }
samples = p.frame_size
case AV_CODEC_ID_WMAV1, AV_CODEC_ID_WMAV2:
let flagOffset = p.codec_id == AV_CODEC_ID_WMAV1 ? 2 : 4
guard p.sample_rate <= 50_000, p.ch_layout.nb_channels > 0,
p.ch_layout.nb_channels <= 2, p.block_align > 0,
Int(p.extradata_size) >= flagOffset + 2, let data = p.extradata else { return nil }
let flags = UInt16(data[flagOffset]) | (UInt16(data[flagOffset + 1]) << 8)
// A reservoir superframe can depend on the previous packet and yield several
// frames: byte length/bitrate is not proof of its sample count.
guard flags & 2 == 0 else { return nil }
if p.sample_rate <= 16_000 { samples = 512 }
else if p.sample_rate <= 22_050 || (p.codec_id == AV_CODEC_ID_WMAV1 && p.sample_rate <= 32_000) {
samples = 1024
} else { samples = 2048 }
return .init(sampleRate: p.sample_rate,
layout: .fixed(samples: samples, packetBytes: Int(p.block_align)))
default:
let bits = av_get_exact_bits_per_sample(p.codec_id)
guard bits > 0, bits % 8 == 0, p.ch_layout.nb_channels > 0,
p.ch_layout.nb_channels <= 64 else { return nil }
return .init(sampleRate: p.sample_rate,
layout: .pcm(bytesPerSampleFrame: Int(bits / 8) * Int(p.ch_layout.nb_channels)))
}
return .init(sampleRate: p.sample_rate, layout: .fixed(samples: samples, packetBytes: nil))
}

/// The software VOD packet store's bounds on tvOS, which keeps it in purgeable memory
/// (`SoftwarePacketMemoryFIFO`) instead of session files; nil elsewhere, where the disk store and
/// the session retention budget apply. On an Apple TV 4K (A12) the disk store's appends took
Expand Down Expand Up @@ -1314,7 +1357,8 @@ final class SoftwarePlaybackHost {
let audio: SoftwarePacketReadAhead.Stream? = audioStreamIndex >= 0
? dem.stream(at: audioStreamIndex).map {
.init(index: audioStreamIndex, numerator: $0.pointee.time_base.num,
denominator: $0.pointee.time_base.den)
denominator: $0.pointee.time_base.den,
audioTiming: Self.audioPacketTiming($0.pointee.codecpar))
} : nil
let initialSourceClock = initialClockTime.seconds
let videoReorderDepth = Self.presentationReorderDepth(codecID: vCodecID)
Expand Down
Loading
Loading