From 2fe2836ee4c8b5726356ba80930d4ebcdda2e59e Mon Sep 17 00:00:00 2001 From: orut34iop Date: Sun, 18 Oct 2026 13:00:25 +0800 Subject: [PATCH 1/2] fix(cache): preserve quantized audio sample clock coverage --- CHANGELOG.md | 2 + .../Native/SoftwareAudioPacketCoverage.swift | 115 ++++++++++++++ .../Native/SoftwarePacketReadAhead.swift | 35 +---- .../Native/SoftwarePlaybackHost.swift | 46 +++++- ...arePacketReadAheadAudioCoverageTests.swift | 145 ++++++++++++++++++ .../SoftwarePacketTimingPolicyTests.swift | 79 ++++++++++ docs/architecture.md | 4 + 7 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 Sources/AetherEngine/Native/SoftwareAudioPacketCoverage.swift create mode 100644 Tests/AetherEngineTests/SoftwarePacketTimingPolicyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f69ff13..2d8269be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/AetherEngine/Native/SoftwareAudioPacketCoverage.swift b/Sources/AetherEngine/Native/SoftwareAudioPacketCoverage.swift new file mode 100644 index 000000000..2b591eb89 --- /dev/null +++ b/Sources/AetherEngine/Native/SoftwareAudioPacketCoverage.swift @@ -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] { + 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 + } +} diff --git a/Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift b/Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift index 4c17996bb..586caef66 100644 --- a/Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift +++ b/Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift @@ -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 @@ -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 @@ -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, @@ -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() } @@ -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() @@ -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 diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift index a388656f6..0e679ba4f 100644 --- a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift +++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift @@ -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) + -> 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 @@ -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) diff --git a/Tests/AetherEngineTests/SoftwarePacketReadAheadAudioCoverageTests.swift b/Tests/AetherEngineTests/SoftwarePacketReadAheadAudioCoverageTests.swift index 0fb0070c5..b11757e35 100644 --- a/Tests/AetherEngineTests/SoftwarePacketReadAheadAudioCoverageTests.swift +++ b/Tests/AetherEngineTests/SoftwarePacketReadAheadAudioCoverageTests.swift @@ -87,3 +87,148 @@ struct SoftwarePacketReadAheadAudioCoverageTests { #expect(try seekHits(source, to: 2.0) == false) } } + + +@Suite(.timeLimit(.minutes(1)), .offCooperativePool) +struct QuantizedAudioReadAheadTests { + private final class Index: @unchecked Sendable { + let lock = NSLock() + var value = 0 + func next() -> Int { lock.lock(); defer { lock.unlock() }; defer { value += 1 }; return value } + } + + @Test("quantized audio ranges, frontier and retained seeks agree", arguments: [false, true], [false, true]) + func realStores(memory: Bool, missingPacket: Bool) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + func packet(_ pts: Int64, stream: Int32 = 0, duration: Int64 = 33, bytes: Int = 64) -> SoftwareStoredPacket { + .init(pts: pts, dts: pts, duration: duration, position: 0, streamIndex: stream, flags: 1, + timeBaseNumerator: 1, timeBaseDenominator: 1000, bytes: Data(repeating: 7, count: bytes), sideData: []) + } + var packets = (0..<360).map { packet(Int64($0) * 1000 / 30) } + for i: Int64 in 0..<259 where !missingPacket || i != 50 { + packets.append(packet(i * 2048 * 1000 / 44100, stream: 1, duration: 46, bytes: 371)) + } + let all = packets.sorted { $0.pts < $1.pts } + let index = Index() + let store: any SoftwarePacketStore = memory + ? SoftwarePacketMemoryFIFO(historyCapBytes: 8 << 20) + : try SoftwarePacketDiskFIFO(chunkTargetBytes: 64 * 1024, retainConsumed: true, parentDirectory: root) + let source = SoftwarePacketReadAhead( + video: .init(index: 0, numerator: 1, denominator: 1000), + audio: .init(index: 1, numerator: 1, denominator: 1000, + audioTiming: .init(sampleRate: 44100, layout: .fixed(samples: 2048, packetBytes: 371))), + byteBudget: 8 << 20, forwardSeconds: 100, initialSourceClock: 0, fifo: store, videoReorderDepth: 1 + ) { _ in let i = index.next(); return i < all.count ? all[i] : nil } + defer { source.close() } + source.start() + try await waitFor { source.snapshot.sourceEnded } + // All records remain in the non-purgeable forward queue: this does not assume consumed + // memory history survives memory pressure (the separate FIFO suites cover that contract). + let ranges = source.residentRanges + #expect(ranges.count == (missingPacket ? 2 : 1)) + #expect((source.snapshot.frontier ?? 0) > (missingPacket ? 2 : 11)) + let target = 2.35 + #expect(ranges.contains { $0.contains(target) } == !missingPacket) + let token = source.beginSeek(to: target) + #expect(try source.prepareSeek(token, to: target) == !missingPacket) + if !missingPacket { + let backwards = source.beginSeek(to: 1) + #expect(try source.prepareSeek(backwards, to: 1)) + #expect(source.snapshot.sourceEpoch == 0) + } + _ = source.beginSeek() + #expect(source.residentRanges.isEmpty) + #expect(source.snapshot.frontier == nil) + } +} + +struct SoftwareAudioPacketCoverageTests { + func ranges(_ model: SoftwareAudioPacketCoverage, denominator: Int32 = 1000) -> [ClosedRange] { + model.rangesSeconds(timeBaseNumerator: 1, timeBaseDenominator: denominator) + } + @Test("sample geometry bridges quantization without hiding holes or phase drift") + func rationalCoverage() { + // Fixed sample geometry is shared by AAC, AC-3, MPEG audio and non-reservoir WMA. + // These are timing fixtures, not codec/file playback acceptance. + for (samples, rate) in [(1024, 44100), (960, 48000), (1536, 44100), (1152, 44100), + (576, 22050), (2048, 44100), (512, 16000), (1024, 32000)] { + for denominator: Int32 in [1000, 90000, Int32(rate)] { + let timing = SoftwareAudioPacketTiming(sampleRate: Int32(rate), layout: .fixed(samples: Int32(samples), packetBytes: nil)) + var model = SoftwareAudioPacketCoverage(timeBaseNumerator: 1, timeBaseDenominator: denominator, timing: timing) + let duration = Int64(samples) * Int64(denominator) / Int64(rate) + for i: Int64 in 0..<12_000 { + let pts = i * Int64(samples) * Int64(denominator) / Int64(rate) + model.insert(pts: pts, duration: duration, byteCount: 371) + } + #expect(ranges(model, denominator: denominator).count == 1, "sample clock fragmented") + let end = ranges(model, denominator: denominator)[0].upperBound + #expect(end > 100, "range cap truncated long run") + model.reset() + #expect(ranges(model, denominator: denominator).isEmpty) + } + } + let rationalTiming = SoftwareAudioPacketTiming(sampleRate: 44100, layout: .fixed(samples: 2048, packetBytes: nil)) + var rational = SoftwareAudioPacketCoverage(timeBaseNumerator: 1001, timeBaseDenominator: 44_100_000, timing: rationalTiming) + for i: Int64 in 0..<2000 { + rational.insert(pts: i * 2048 * 1000 / 1001, duration: 2048 * 1000 / 1001, byteCount: 371) + } + #expect(rational.rangesSeconds(timeBaseNumerator: 1001, timeBaseDenominator: 44_100_000).count == 1) + var integralGap = SoftwareAudioPacketCoverage(timeBaseNumerator: 1, timeBaseDenominator: 1000, + timing: .init(sampleRate: 48000, layout: .fixed(samples: 1536, packetBytes: nil))) + integralGap.insert(pts: 0, duration: 32, byteCount: 371) + integralGap.insert(pts: 33, duration: 32, byteCount: 371) + #expect(ranges(integralGap).count == 2, "an integral clock has no quantization gap to excuse") + var phaseGap = SoftwareAudioPacketCoverage(timeBaseNumerator: 1, timeBaseDenominator: 1000, timing: rationalTiming) + for i: Int64 in 0..<2000 { + phaseGap.insert(pts: i * 2048 * 1000 / 44100 + (i >= 1000 ? 1 : 0), duration: 46, byteCount: 371) + } + #expect(ranges(phaseGap).count == 2, "a real one-tick phase jump after a locked cadence must survive") + let wma = SoftwareAudioPacketTiming(sampleRate: 44100, layout: .fixed(samples: 2048, packetBytes: 371)) + func make(_ timing: SoftwareAudioPacketTiming? = wma) -> SoftwareAudioPacketCoverage { + .init(timeBaseNumerator: 1, timeBaseDenominator: 1000, timing: timing) + } + var valid = make() + for pts: Int64 in [0, 46, 92, 139, 185] { valid.insert(pts: pts, duration: 46, byteCount: 371) } + #expect(ranges(valid).count == 1) + var unknown = make(nil) + for pts: Int64 in [0, 46, 92, 139, 185] { unknown.insert(pts: pts, duration: 46, byteCount: 371) } + #expect(ranges(unknown).count == 2, "unknown positive durations must remain strict") + var wrongSize = make() + for pts: Int64 in [0, 46, 92, 139, 185] { wrongSize.insert(pts: pts, duration: 46, byteCount: 742) } + #expect(ranges(wrongSize).count == 2, "packed/reservoir payload cannot inherit fixed sample geometry") + var drift = make() + for i: Int64 in 0..<100 { drift.insert(pts: 47 * i, duration: 46, byteCount: 371) } + #expect(ranges(drift).count > 30, "one tick per packet must not be swallowed") + var hole = make() + hole.insert(pts: 0, duration: 46, byteCount: 371) + hole.insert(pts: 47, duration: 46, byteCount: 371) + hole.insert(pts: 140, duration: 46, byteCount: 371) + #expect(!ranges(hole).contains { $0.contains(0.12) }, "missing packet bridged") + var invalid = make() + invalid.insert(pts: 0, duration: 46, byteCount: 371) + invalid.insert(pts: Int64.min, duration: 46, byteCount: 371) + invalid.insert(pts: 47, duration: 46, byteCount: 371) + #expect(ranges(invalid).count == 2, "missing PTS did not break evidence") + var legacy = make(nil) + for pts: Int64 in [0, 0, 1, 2, 2, 3, 1000, 1001] { legacy.insert(pts: pts, duration: 0, byteCount: 40) } + #expect(ranges(legacy).count == 2) + #expect(!ranges(legacy).contains { $0.contains(0.5) }) + var overflow = make() + for pts: Int64 in [Int64.min + 1, Int64.max - 100, 0, -100, Int64.max] { + overflow.insert(pts: pts, duration: 46, byteCount: 371) + } + var badTimebase = SoftwareAudioPacketCoverage(timeBaseNumerator: 0, timeBaseDenominator: 1, timing: wma) + badTimebase.insert(pts: 0, duration: 46, byteCount: 371) + #expect(ranges(badTimebase).isEmpty) + let pcm = SoftwareAudioPacketTiming(sampleRate: 44100, layout: .pcm(bytesPerSampleFrame: 4)) + #expect(pcm.samples(byteCount: 4096) == 1024 && pcm.samples(byteCount: 4097) == nil) + var mismatch = make() + mismatch.insert(pts: 0, duration: 20, byteCount: 371) + mismatch.insert(pts: 47, duration: 46, byteCount: 371) + #expect(ranges(mismatch).count == 2) + let last = ranges(valid).last!.upperBound + #expect(abs(last - 0.231) < 0.000001, "final packet invents unverified coverage") + } +} diff --git a/Tests/AetherEngineTests/SoftwarePacketTimingPolicyTests.swift b/Tests/AetherEngineTests/SoftwarePacketTimingPolicyTests.swift new file mode 100644 index 000000000..48a767a7a --- /dev/null +++ b/Tests/AetherEngineTests/SoftwarePacketTimingPolicyTests.swift @@ -0,0 +1,79 @@ +import Foundation +import AetherLibavcodec +import Testing +@testable import AetherEngine + +@Suite("Packet timing admission uses codec sample geometry") +struct SoftwarePacketTimingPolicyTests { + @Test func fixedAudioAndConservativeUnknowns() throws { + let p = try #require(avcodec_parameters_alloc()) + var allocated: UnsafeMutablePointer? = p + defer { avcodec_parameters_free(&allocated) } + p.pointee.sample_rate = 44100 + for (codec, count) in [(AV_CODEC_ID_AC3, 1536), (AV_CODEC_ID_MP1, 384), + (AV_CODEC_ID_MP2, 1152), (AV_CODEC_ID_MP3, 1152)] { + p.pointee.codec_id = codec + #expect(SoftwarePlaybackHost.audioPacketTiming(p)?.samples(byteCount: 371) == Int64(count)) + } + p.pointee.codec_id = AV_CODEC_ID_AAC + #expect(SoftwarePlaybackHost.audioPacketTiming(p) == nil) + p.pointee.frame_size = 1024 + #expect(SoftwarePlaybackHost.audioPacketTiming(p)?.samples(byteCount: 371) == 1024) + // Even a populated frame_size is not proof for arbitrary variable-frame codecs. + for codec in [AV_CODEC_ID_FLAC, AV_CODEC_ID_OPUS, AV_CODEC_ID_DTS, AV_CODEC_ID_TRUEHD, + AV_CODEC_ID_WMAPRO, AV_CODEC_ID_WMALOSSLESS] { + p.pointee.codec_id = codec + #expect(SoftwarePlaybackHost.audioPacketTiming(p) == nil) + } + } + + @Test func wmaRequiresNonReservoirPacketLayout() throws { + let p = try #require(avcodec_parameters_alloc()) + var allocated: UnsafeMutablePointer? = p + defer { avcodec_parameters_free(&allocated) } + // Borrowed bytes are detached before avcodec_parameters_free takes ownership. + var extra: [UInt8] = [0, 0, 0, 0, 1, 0] + extra.withUnsafeMutableBufferPointer { bytes in + p.pointee.extradata = bytes.baseAddress + p.pointee.extradata_size = 6 + defer { p.pointee.extradata = nil; p.pointee.extradata_size = 0 } + p.pointee.codec_id = AV_CODEC_ID_WMAV2 + p.pointee.sample_rate = 44100 + p.pointee.ch_layout.nb_channels = 2 + p.pointee.block_align = 371 + p.pointee.bit_rate = 64000 // must never turn 2048 real samples into 2045 estimated ones + #expect(SoftwarePlaybackHost.audioPacketTiming(p)?.samples(byteCount: 371) == 2048) + #expect(SoftwarePlaybackHost.audioPacketTiming(p)?.samples(byteCount: 742) == nil) + bytes[4] = 3 + #expect(SoftwarePlaybackHost.audioPacketTiming(p) == nil) + bytes[4] = 1 + p.pointee.extradata_size = 0 + #expect(SoftwarePlaybackHost.audioPacketTiming(p) == nil) + } + } +} + +extension SoftwarePacketTimingPolicyTests { + @Test func pcmAndLowerRateMPEGGeometry() throws { + var allocated = avcodec_parameters_alloc() + let p = try #require(allocated) + defer { avcodec_parameters_free(&allocated) } + p.pointee.codec_id = AV_CODEC_ID_MP3 + p.pointee.sample_rate = 22050 + #expect(SoftwarePlaybackHost.audioPacketTiming(p)?.samples(byteCount: 100) == 576) + p.pointee.codec_id = AV_CODEC_ID_PCM_S24LE + p.pointee.sample_rate = 48000 + p.pointee.ch_layout.nb_channels = 6 + #expect(SoftwarePlaybackHost.audioPacketTiming(p)?.samples(byteCount: 1800) == 100) + #expect(SoftwarePlaybackHost.audioPacketTiming(p)?.samples(byteCount: 1801) == nil) + p.pointee.ch_layout.nb_channels = 0 + #expect(SoftwarePlaybackHost.audioPacketTiming(p) == nil) + p.pointee.codec_id = AV_CODEC_ID_EAC3 + p.pointee.frame_size = 0 + #expect(SoftwarePlaybackHost.audioPacketTiming(p) == nil) + p.pointee.frame_size = 1536 + #expect(SoftwarePlaybackHost.audioPacketTiming(p)?.samples(byteCount: 100) == 1536) + p.pointee.sample_rate = 0 + #expect(SoftwarePlaybackHost.audioPacketTiming(p) == nil) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 45d8c0ab3..0b52269ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -715,3 +715,7 @@ never repeats an already completed startup cushion wait. The change does not cut a GOP at a dependent frame or falsify a segment's duration. A consumer item replacement can still cause a short interruption; missing source frames cannot be reconstructed by this policy. + +### Quantized audio packet coverage + +`SoftwareAudioPacketCoverage` retains feasible integer sample-clock phases across admitted audio packets rather than bridging every small gap with an epsilon. Codec geometry is limited to fixed MPEG audio/AC-3 frames, declared AAC/E-AC-3 frame sizes, complete non-reservoir WMA v1/v2 blocks and exact PCM sample frames. Unknown, packed or conflicting layouts keep strict positive-duration intervals. Missing timestamps, backwards transitions and arithmetic overflow break clock evidence; a genuine missing packet or accumulated extra tick remains a hole. This changes packet-cache coverage and seek admission, not the decoded audio output clock. Duration-less audio retains the bounded successor-PTS contract, including repeated TrueHD millisecond timestamps. From 67f1b9f2be43ad8a4b2f32885804fe80afbd2b8b Mon Sep 17 00:00:00 2001 From: orut34iop Date: Sun, 18 Oct 2026 13:30:45 +0800 Subject: [PATCH 2/2] fix(ci): include audio coverage in standalone read-ahead build --- Scripts/test-software-packet-read-ahead.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/Scripts/test-software-packet-read-ahead.sh b/Scripts/test-software-packet-read-ahead.sh index 68195fdd0..ee64ef5ed 100644 --- a/Scripts/test-software-packet-read-ahead.sh +++ b/Scripts/test-software-packet-read-ahead.sh @@ -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" \