diff --git a/CHANGELOG.md b/CHANGELOG.md index 889b8ca9..1275e944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,31 @@ the public-API contract. ## [Unreleased] -_Nothing yet._ +### Fixed + +- **A transcoding audio bridge that produces nothing now says so, instead of dying as a muxer error two + subsystems downstream (AE#396).** A plain SD MKV with mono MP3 audio failed on the native route at + nine of nine start positions, ending on `Source audio cannot be muxed (code -22)` after three + identical revive attempts. The muxer was right and innocent: FFmpeg's mp4 muxer can only build an + AC-3/E-AC-3 sample entry from a packet that has been written, and for a bridged source those packets + come from the bridge's encoder, which had emitted none. Nothing anywhere in the session said that. + Every step between a source packet and an encoded frame ends in a `return` or in a loop that stops on + a negative code (a packet the decoder rejects, a decoder that answers nothing, a resample that + converts to zero samples, an encoder that keeps its output), which is correct per packet and silent in + aggregate, so a bridge that emitted nothing for a whole first segment was indistinguishable from one + that had simply not been asked yet. `AudioBridge` now counts each of those arms and keeps the + decoder's own error code, reports once (`AE#396 the bridge has produced no encoded audio at all`) as + soon as enough source has gone in for the silence to be structural, and the deferred first cut prints + the bridge's account instead of announcing a prime scan it does not run on this path. +- **A bridged session whose audio decoded to nothing fails immediately and truthfully, rather than + spending its revive budget re-reading the same bytes.** A producer restart rebuilds the muxer and + re-opens the encoder, both downstream of a failing decoder, so the same bytes were read three times + for the same answer. Zero decoded frames now ends the session at once; frames decoded with no packets + emitted is the encoder side, which a rebuild does heal, and keeps its revive. +- **New `PlaybackErrorKind.audioBridgeProducedNoOutput` for that failure.** It used to arrive as + `.vodSourceFailed`, which reads as "the source is gone" and ends a host's fallback ladder; the source + is neither gone nor unreadable here, and a second player that decodes the track itself plays the file. + Hosts with a ladder should demote on this kind, not stop. ## [6.30.2] - 2026-08-18 diff --git a/Sources/AetherEngine/Audio/AudioBridge.swift b/Sources/AetherEngine/Audio/AudioBridge.swift index d8cf6121..cd78fb5d 100644 --- a/Sources/AetherEngine/Audio/AudioBridge.swift +++ b/Sources/AetherEngine/Audio/AudioBridge.swift @@ -413,6 +413,81 @@ final class AudioBridge: @unchecked Sendable { /// on the pump thread; read lock-free for diagnostics, mirroring `liveBytes`. private(set) var outputBytesLifetime: Int64 = 0 + /// AE#396: what the bridge has actually done with what it was handed. + /// + /// Every step between a source packet and an encoded frame can fail per packet, and each of those + /// arms ends in a `return` or in a loop that stops on a negative code: a packet the decoder + /// rejects, a decoder that answers nothing, a resample that converts to zero samples, an encoder + /// that keeps its output. Per packet that is the right behaviour, one bad frame must not end a + /// session. In aggregate it is the worst failure this class has, because a bridge that emits + /// NOTHING for a whole first segment surfaces as movenc's -22: the mp4 muxer is asked for an + /// AC-3/E-AC-3 sample entry it can only build from a packet that was never written, the cut + /// fails, the revive re-reads the same bytes twice more, and the session ends on "Source audio + /// cannot be muxed". Neither that sentence nor any line before it mentions audio decoding, so the + /// only subsystem that is definitely innocent is the one everybody reads about. + /// + /// These counters exist to name the arm. Written under `opLock` on the pump thread, read + /// lock-free for diagnostics, exactly like `outputBytesLifetime`. + struct FeedStats: Sendable, Equatable { + var packetsFed = 0 + /// Source packets `avcodec_send_packet` rejected and `feed` skipped (`invalidData`, `einval`). + var packetsRejected = 0 + var framesDecoded = 0 + /// `avcodec_receive_frame` answers that were neither a frame nor EAGAIN/EOF. + var decodeErrors = 0 + var lastDecodeErrorCode: Int32 = 0 + /// Decoded frames that never reached the FIFO: no samples, a null plane, or a resample that + /// produced nothing. + var framesDroppedBeforeFIFO = 0 + var samplesEnqueued: Int64 = 0 + var packetsEmitted = 0 + /// `avcodec_receive_packet` answers that were neither a packet nor EAGAIN/EOF. + var encodeErrors = 0 + var lastEncodeErrorCode: Int32 = 0 + + /// Fed real source, answered with nothing at all. + var isSilent: Bool { packetsFed > 0 && packetsEmitted == 0 } + + /// The DECODER is the arm that failed, which no rebuild can change: a producer restart opens a + /// fresh encoder (#99 failure mode B) but hands the same decoder the same bytes. That is the + /// line between a muxer revive that can heal and three attempts that read identically. + var decodedNothing: Bool { packetsFed > 0 && framesDecoded == 0 } + + var summary: String { + var parts = ["fed=\(packetsFed)", "decoded=\(framesDecoded)", + "enqueued=\(samplesEnqueued)", "emitted=\(packetsEmitted)"] + if packetsRejected > 0 { parts.append("rejected=\(packetsRejected)") } + if framesDroppedBeforeFIFO > 0 { + parts.append("droppedBeforeFIFO=\(framesDroppedBeforeFIFO)") + } + if decodeErrors > 0 { + parts.append("decodeErrors=\(decodeErrors)") + parts.append("lastDecodeError=\(FFmpegErr.text(for: lastDecodeErrorCode))") + } else if packetsRejected > 0, lastDecodeErrorCode != 0 { + parts.append("lastDecodeError=\(FFmpegErr.text(for: lastDecodeErrorCode))") + } + if encodeErrors > 0 { + parts.append("encodeErrors=\(encodeErrors)") + parts.append("lastEncodeError=\(FFmpegErr.text(for: lastEncodeErrorCode))") + } + return parts.joined(separator: " ") + } + } + + private var stats = FeedStats() + + /// Snapshot for the producer and the engine. The pump thread is the only writer, and both readers + /// run on it (the deferred-cut log site and the pump-finished handler). + var feedStats: FeedStats { stats } + + /// One-shot: a bridge that stays silent for an hour costs one line, not one per packet. + private var silentFeedReported = false + + /// Enough source that no start-up latency explains the silence. The largest encoder frame the + /// bridge builds is 1536 samples (E-AC-3) and the smallest source packet it is fed is 512 (DTS), + /// so three packets is the honest worst case before the first output and 64 is two orders past it. + private static let silentFeedPacketThreshold = 64 + /// Snapshot of bytes live in the bridge's growable buffers, for the engine memory probe. Both fields grow on /// the FFmpeg side (FIFO reallocs upward, swr delay buffer reallocates on rate/layout shift), so a /// monotonically rising value points here vs the segment muxer or HLS server. Costs: two C calls, no allocations. @@ -641,6 +716,8 @@ final class AudioBridge: @unchecked Sendable { return [] } + stats.packetsFed += 1 + var results: [UnsafeMutablePointer] = [] // Capture packet.pts for the encoder-PTS rebase, NOT the decoded frame's pts. Issue #7: for codecs with @@ -658,7 +735,20 @@ final class AudioBridge: @unchecked Sendable { // Drain every decodable frame into the FIFO. The PTS rebase fires on the first frame after a segment // boundary so FLAC timestamps track the source rather than drifting on FIFO leftover (uses packetPts, not sf.pts). func receiveDecodedFrames() throws { - while avcodec_receive_frame(dec, sf) >= 0 { + while true { + // AE#396: the loop used to be `while avcodec_receive_frame(...) >= 0`, which reads + // "drain what is there" and behaves as "drop the reason there is nothing". A decoder + // that rejects every frame of a stream is then indistinguishable from one that has + // simply caught up, for the whole session. + let receiveRet = avcodec_receive_frame(dec, sf) + if receiveRet < 0 { + if receiveRet != FFmpegErr.eagain, receiveRet != FFmpegErr.eof { + stats.decodeErrors += 1 + stats.lastDecodeErrorCode = receiveRet + } + break + } + stats.framesDecoded += 1 if rebaseFromNextSourcePTS, packetPts != Self.avNoPTS { nextEncoderPTS = av_rescale_q(packetPts, srcTimeBase, encoderTimeBase) rebaseFromNextSourcePTS = false @@ -676,6 +766,8 @@ final class AudioBridge: @unchecked Sendable { sendRet = avcodec_send_packet(dec, pkt) } if sendRet == FFmpegErr.invalidData || sendRet == FFmpegErr.einval { + stats.packetsRejected += 1 + stats.lastDecodeErrorCode = sendRet // Skippable single-packet rejections, decoder stays usable for the next packet: // invalidData = corrupt source packet (glitchy live MPEG-TS, broken mp2 header); // einval (-22) = a DTS-HD MA XLL frame that residual-codes channels without a usable core @@ -709,9 +801,28 @@ final class AudioBridge: @unchecked Sendable { throw error } + noteSilentFeedIfNeeded() return results } + /// AE#396: say it once, the moment enough source has gone in for the silence to be structural + /// rather than start-up latency. Without this the only downstream evidence is movenc's -22 on a + /// moov it cannot build, which names the muxer and the source and never the bridge. `EngineLog` + /// has no error level, so the `ERROR:` prefix carries it, as in the #165 cascade line. + private func noteSilentFeedIfNeeded() { + guard !silentFeedReported, + stats.isSilent, + stats.packetsFed >= Self.silentFeedPacketThreshold else { return } + silentFeedReported = true + EngineLog.emit( + "[AudioBridge] ERROR: AE#396 the bridge has produced no encoded audio at all " + + "(mode=\(mode.rawValue)): \(stats.summary). The mp4 muxer can only build an " + + "AC-3/E-AC-3 sample entry from a packet that was written, so this session will fail " + + "its first segment cut unless output starts.", + category: .session + ) + } + /// Align swr's INPUT side to the frame the decoder actually produced. libswresample reads `extended_data` /// planes strictly per its configured input format, so if the decoder emits a different (sample_fmt, /// sample_rate, ch_layout) than swr was set up for, the bytes are misread: lossless DTS-HD MA decodes to @@ -785,17 +896,26 @@ final class AudioBridge: @unchecked Sendable { // EXC_BAD_ACCESS at 0x0. Skip such frames (the video path tolerates the same corruption). guard sf.pointee.nb_samples > 0, let ext = sf.pointee.extended_data, - ext.pointee != nil else { return } + ext.pointee != nil else { + stats.framesDroppedBeforeFIFO += 1 + return + } // Align swr's INPUT to the frame the decoder actually produced before converting. No-op once the seed // matched (the usual case); only a wrong init seed or a genuine mid-stream format change rebuilds swr. reconfigureSwrInputIfNeeded(forFrame: sf, enc: enc) // The rebuild reuses the context pointer on success, but swr_alloc_set_opts2 frees it on a set-opts // failure (swr_free(ps) -> swrCtx == nil), which would dangle the caller's `swr`. Re-bind to the live one. - guard let swr = swrCtx else { return } + guard let swr = swrCtx else { + stats.framesDroppedBeforeFIFO += 1 + return + } let outNbSamples = swr_get_out_samples(swr, sf.pointee.nb_samples) - guard outNbSamples > 0 else { return } + guard outNbSamples > 0 else { + stats.framesDroppedBeforeFIFO += 1 + return + } let nChannels = enc.pointee.ch_layout.nb_channels let isPlanar = av_sample_fmt_is_planar(pcmSampleFmt) != 0 @@ -827,16 +947,20 @@ final class AudioBridge: @unchecked Sendable { ) } } - guard producedSamples > 0 else { return } + guard producedSamples > 0 else { + stats.framesDroppedBeforeFIFO += 1 + return + } // av_audio_fifo_write takes void **data; the same array works for both layouts (FIFO knows the format). - _ = outPtrs.withUnsafeMutableBufferPointer { fifoBuf in + let written = outPtrs.withUnsafeMutableBufferPointer { fifoBuf in fifoBuf.baseAddress!.withMemoryRebound( to: UnsafeMutableRawPointer?.self, capacity: bufferCount ) { rebound in av_audio_fifo_write(fifo, rebound, producedSamples) } } + if written > 0 { stats.samplesEnqueued += Int64(written) } } /// Pull frame_size chunks from the FIFO and encode each. requireFull true stops below frame_size (streaming); @@ -904,11 +1028,14 @@ final class AudioBridge: @unchecked Sendable { break } if recvRet < 0 { + stats.encodeErrors += 1 + stats.lastEncodeErrorCode = recvRet var p: UnsafeMutablePointer? = outPkt trackedPacketFree(&p) break } outputBytesLifetime += Int64(outPkt.pointee.size) + stats.packetsEmitted += 1 results.append(outPkt) } } diff --git a/Sources/AetherEngine/PlaybackErrorInfo.swift b/Sources/AetherEngine/PlaybackErrorInfo.swift index 9a52cdef..fc097f66 100644 --- a/Sources/AetherEngine/PlaybackErrorInfo.swift +++ b/Sources/AetherEngine/PlaybackErrorInfo.swift @@ -67,6 +67,13 @@ public struct PlaybackErrorKind: RawRepresentable, Sendable, Equatable, Hashable public static let liveReloadNeverReady = PlaybackErrorKind(rawValue: "liveReloadNeverReady") /// An audio-track switch failed; the session it replaced is gone with it. public static let audioTrackSwitchFailed = PlaybackErrorKind(rawValue: "audioTrackSwitchFailed") + /// A source whose audio has to be transcoded (MP3, MP2, DTS, TrueHD, Vorbis, PCM: anything not + /// legal for stream-copy into fMP4) produced no encoded audio at all, so the mp4 muxer could not + /// build the sample entry it can only derive from a written packet and the first segment cut + /// failed (AE#396). Distinct from `vodSourceFailed`, which this used to arrive as: the source is + /// neither gone nor unreadable, and a second player that decodes the track itself will play it. + /// A host with a fallback ladder should DEMOTE on this one, not end the ladder. + public static let audioBridgeProducedNoOutput = PlaybackErrorKind(rawValue: "audioBridgeProducedNoOutput") } /// Machine-readable companion to the text inside `PlaybackState.error` (#376). diff --git a/Sources/AetherEngine/Video/HLSSegmentProducer.swift b/Sources/AetherEngine/Video/HLSSegmentProducer.swift index ebe43c77..825d2311 100644 --- a/Sources/AetherEngine/Video/HLSSegmentProducer.swift +++ b/Sources/AetherEngine/Video/HLSSegmentProducer.swift @@ -376,6 +376,11 @@ final class HLSSegmentProducer: @unchecked Sendable { } /// Set by the host from a previous producer's verdict; skips the search entirely. private let audioMoovPrimeKnownUnobtainable: Bool + + /// AE#396: the bridge's own account of what it did with the source, for the pump-finished handler. + /// Nil for a stream-copy session, which has no bridge and whose analogous verdict is + /// `audioMoovPrimeUnobtainable`. + var audioBridgeFeedStats: AudioBridge.FeedStats? { audioConfig?.bridge?.feedStats } private var currentMuxer: MP4SegmentMuxer? private var currentMuxerSegmentIndex: Int = .min @@ -1739,11 +1744,26 @@ final class HLSSegmentProducer: @unchecked Sendable { // order). Nothing was written, so the pump exits to let the host rebuild with a prime frame; the // scan for that frame happens on the way out. cutDeferredAwaitingAudioSampleEntry = true - EngineLog.emit( - "[HLSSegmentProducer] AE#222 seg-\(currentMuxerSegmentIndex).m4s cut deferred: audio sample " - + "entry needs a parsed packet and none has been muxed; scanning forward for a prime frame", - category: .session - ) + // AE#396: the two ways to arrive here are not the same defect and must not read the same. + // Stream-copy audio is missing a SOURCE packet, which the prime scan goes looking for. A + // bridged session's muxed frames come from the encoder, so there is nothing in the source + // to scan for and `scanForAudioMoovPrimeFrame` returns without looking: announcing a scan + // there described an action that never happened, on the one path where the interesting + // question ("why has the bridge emitted nothing?") had no line at all. + if let bridge = audioConfig?.bridge { + EngineLog.emit( + "[HLSSegmentProducer] AE#396 seg-\(currentMuxerSegmentIndex).m4s cut deferred: the " + + "audio sample entry is built from a BRIDGED packet and the bridge has muxed none. " + + "Bridge: \(bridge.feedStats.summary)", + category: .session + ) + } else { + EngineLog.emit( + "[HLSSegmentProducer] AE#222 seg-\(currentMuxerSegmentIndex).m4s cut deferred: audio sample " + + "entry needs a parsed packet and none has been muxed; scanning forward for a prime frame", + category: .session + ) + } return nil case .failed: // Failed cut: muxer has no open staging fd, every byte is silently discarded. Fatal. diff --git a/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift b/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift index 4ef8ae84..70db8884 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine+LiveReopen.swift @@ -196,6 +196,23 @@ extension HLSVideoEngine { sessionAudioMoovPrimeUnobtainable = true restartLock.unlock() } + // AE#396: a bridged session whose DECODER produced not one frame has nothing a revive can + // reach. The restart path rebuilds the muxer and re-opens the encoder (#99 failure mode B), + // and both sit downstream of the arm that failed: the same decoder is handed the same bytes + // and answers the same way, which is exactly what the reporter measured, three attempts with + // identical packet counts finishing in 12 to 23 ms. Spend the words instead of the budget. + // Frames decoded but nothing emitted is the ENCODER side, which a rebuild does heal, so that + // shape keeps its revive. + if !isLiveSession, let bridge = prod.audioBridgeFeedStats, bridge.decodedNothing { + EngineLog.emit( + "[HLSVideoEngine] AE#396 the audio bridge decoded nothing, so the mp4 sample entry " + + "can never be built and a revive would re-read the same bytes: \(bridge.summary)", + category: .session + ) + surfaceVODSourceFailure(FFmpegErr.einval, "Audio track could not be decoded", + kind: .audioBridgeProducedNoOutput) + return + } if isLiveSession { handleLiveMuxerFailure(prod) } else { @@ -590,6 +607,22 @@ extension HLSVideoEngine { // with nothing in it to act on. The readError arm above has surfaced its own exhaustion // since AE#169; this is the same shape and gets the same last word. -22 is what movenc // returns for the moov it cannot write, so the code carries the real cause. + // + // AE#396: which cause that is depends on whether the audio was bridged. A silent bridge is + // not a source that cannot be muxed, it is a source whose audio this engine could not + // TRANSCODE, and the two ask a host for opposite things: `vodSourceFailed` reads as "the + // source is gone" and ends a fallback ladder, while a second player that decodes the track + // itself plays this file. So name the bridge when the bridge is the one that stayed quiet. + if let bridge = audioBridge?.feedStats, bridge.packetsEmitted == 0 { + EngineLog.emit( + "[HLSVideoEngine] AE#396 the moov was never buildable because the audio bridge " + + "emitted nothing this session: \(bridge.summary)", + category: .session + ) + surfaceVODSourceFailure(FFmpegErr.einval, "Audio could not be transcoded for playback", + kind: .audioBridgeProducedNoOutput) + return + } surfaceVODSourceFailure(FFmpegErr.einval, "Source audio cannot be muxed") return } diff --git a/Tests/AetherEngineTests/Issue396SilentAudioBridgeTests.swift b/Tests/AetherEngineTests/Issue396SilentAudioBridgeTests.swift new file mode 100644 index 00000000..f28f785e --- /dev/null +++ b/Tests/AetherEngineTests/Issue396SilentAudioBridgeTests.swift @@ -0,0 +1,226 @@ +import Testing +import Foundation +import Libavcodec +import Libavutil +@testable import AetherEngine + +/// AE#396: a plain SD MKV with mono MP3 audio failed on the native route at every start position, +/// ending on `Source audio cannot be muxed (code -22)`. The muxer was right and innocent: it can only +/// build an AC-3/E-AC-3 sample entry from a packet that was written, and the bridge had written none. +/// +/// Nothing said so. Every step between a source packet and an encoded frame ends in a `return` or a +/// loop that stops on a negative code, so a bridge that emitted nothing for a whole segment was +/// indistinguishable from one that was simply not asked yet, and the only sentence the session ever +/// produced named the muxer and the source. These cover the counters that name the arm and the +/// classification that follows from them. +@Suite("AE#396 a silent audio bridge names itself") +struct Issue396SilentAudioBridgeTests { + + // MARK: - Fixtures + + /// Little-endian 16-bit PCM WAV with a 440 Hz sine, built in memory. + private func makeWAV(sampleRate: Int, channels: Int, seconds: Double) -> Data { + let frames = Int(Double(sampleRate) * seconds) + var pcm = Data(capacity: frames * channels * 2) + for n in 0.. (packets: [UnsafeMutablePointer], + codecpar: UnsafeMutablePointer, + timeBase: AVRational, + demuxer: Demuxer) { + let demuxer = Demuxer() + try demuxer.open(reader: DataIOReader(data: wav)) + let audioIdx = demuxer.audioStreamIndex + guard audioIdx >= 0, let stream = demuxer.stream(at: audioIdx) else { + throw NSError(domain: "test", code: 1) + } + var packets: [UnsafeMutablePointer] = [] + while let packet = try demuxer.readPacket() { + if packet.pointee.stream_index == audioIdx { + packets.append(packet) + } else { + var p: UnsafeMutablePointer? = packet + trackedPacketFree(&p) + } + } + return (packets, stream.pointee.codecpar, stream.pointee.time_base, demuxer) + } + + private func freeAll(_ packets: inout [UnsafeMutablePointer]) { + for p in packets { + var pp: UnsafeMutablePointer? = p + trackedPacketFree(&pp) + } + packets.removeAll() + } + + /// Mono 44.1 kHz MP3 parameters, the reporter's selected track, with no media behind them: the + /// packets below carry bytes the mp3 decoder cannot make a frame of, which is the shape a + /// bridge that answers nothing produces from the outside. + private func makeMP3Codecpar() -> UnsafeMutablePointer { + let par = avcodec_parameters_alloc()! + par.pointee.codec_type = AVMEDIA_TYPE_AUDIO + par.pointee.codec_id = AV_CODEC_ID_MP3 + par.pointee.sample_rate = 44_100 + par.pointee.format = AV_SAMPLE_FMT_FLTP.rawValue + par.pointee.bit_rate = 40_000 + av_channel_layout_default(&par.pointee.ch_layout, 1) + return par + } + + private func makeUndecodablePackets(count: Int) -> [UnsafeMutablePointer] { + (0..= 0 else { return nil } + memset(pkt.pointee.data, 0xFF, 130) + pkt.pointee.pts = Int64(i) * 26 + pkt.pointee.dts = pkt.pointee.pts + return pkt + } + } + + // MARK: - The counters + + @Test("a bridge fed real audio reports what it produced") + func healthyBridgeReportsItsOutput() throws { + let wav = makeWAV(sampleRate: 48_000, channels: 2, seconds: 0.5) + var (packets, codecpar, tb, demuxer) = try readAudioPackets(wav: wav) + defer { freeAll(&packets); demuxer.close() } + + let bridge = try AudioBridge(srcCodecpar: codecpar, srcTimeBase: tb, mode: .surroundCompat) + defer { bridge.close() } + + var outputs: [UnsafeMutablePointer] = [] + defer { freeAll(&outputs) } + for p in packets { outputs.append(contentsOf: try bridge.feed(packet: p)) } + + let stats = bridge.feedStats + #expect(stats.packetsFed == packets.count) + #expect(stats.framesDecoded > 0) + #expect(stats.samplesEnqueued > 0) + #expect(stats.packetsEmitted == outputs.count) + #expect(stats.packetsEmitted > 0) + #expect(!stats.isSilent, "a bridge that emitted packets must never read as silent") + #expect(!stats.decodedNothing) + } + + @Test("a source the decoder rejects reads as decoded nothing, with the decoder's own code") + func undecodableSourceIsNamedAsADecodeFailure() throws { + let codecpar = makeMP3Codecpar() + defer { + var p: UnsafeMutablePointer? = codecpar + avcodec_parameters_free(&p) + } + let bridge = try AudioBridge(srcCodecpar: codecpar, + srcTimeBase: AVRational(num: 1, den: 1000), + mode: .surroundCompat) + defer { bridge.close() } + + var packets = makeUndecodablePackets(count: 80) + defer { freeAll(&packets) } + #expect(packets.count == 80) + + var outputs: [UnsafeMutablePointer] = [] + defer { freeAll(&outputs) } + for p in packets { outputs.append(contentsOf: (try? bridge.feed(packet: p)) ?? []) } + + let stats = bridge.feedStats + #expect(outputs.isEmpty) + #expect(stats.packetsFed == 80) + #expect(stats.framesDecoded == 0) + #expect(stats.packetsEmitted == 0) + #expect(stats.isSilent) + #expect(stats.decodedNothing, + "zero decoded frames is the arm a producer restart cannot heal") + #expect(stats.lastDecodeErrorCode != 0, + "the decoder's own code is the only thing that says WHY, and it used to be discarded") + #expect(stats.summary.contains("decoded=0")) + #expect(stats.summary.contains("emitted=0")) + } + + // MARK: - The classification that follows + + private final class SurfacedFailure: @unchecked Sendable { + private let lock = NSLock() + private var value: (code: Int32, reason: String, kind: PlaybackErrorKind)? + var snapshot: (code: Int32, reason: String, kind: PlaybackErrorKind)? { + lock.lock(); defer { lock.unlock() } + return value + } + func set(_ code: Int32, _ reason: String, _ kind: PlaybackErrorKind) { + lock.lock(); value = (code, reason, kind); lock.unlock() + } + } + + private func makeEngine() -> HLSVideoEngine { + HLSVideoEngine(url: URL(fileURLWithPath: "/nonexistent/ae396.mkv"), dvModeAvailable: false) + } + + @Test("an exhausted revive on a bridge that emitted nothing blames the bridge, not the source") + func exhaustedGateNamesTheSilentBridge() throws { + let codecpar = makeMP3Codecpar() + defer { + var p: UnsafeMutablePointer? = codecpar + avcodec_parameters_free(&p) + } + let bridge = try AudioBridge(srcCodecpar: codecpar, + srcTimeBase: AVRational(num: 1, den: 1000), + mode: .surroundCompat) + defer { bridge.close() } + + let engine = makeEngine() + engine.audioBridge = bridge + engine.muxerFailureReviveGate = MuxerFailureReviveGate(maxAttempts: 0) + let surfaced = SurfacedFailure() + engine.onVODSourceFailed = { code, reason, kind in surfaced.set(code, reason, kind) } + + engine.handleVODMuxerFailure() + + #expect(surfaced.snapshot?.kind == .audioBridgeProducedNoOutput, + "vodSourceFailed reads as a dead source and ends a host's fallback ladder") + #expect(surfaced.snapshot?.code == FFmpegErr.einval) + } + + @Test("an exhausted revive on a bridge that DID emit keeps the muxer verdict") + func exhaustedGateKeepsTheMuxerVerdictWhenTheBridgeProduced() throws { + let wav = makeWAV(sampleRate: 48_000, channels: 2, seconds: 0.5) + var (packets, codecpar, tb, demuxer) = try readAudioPackets(wav: wav) + defer { freeAll(&packets); demuxer.close() } + + let bridge = try AudioBridge(srcCodecpar: codecpar, srcTimeBase: tb, mode: .surroundCompat) + defer { bridge.close() } + var outputs: [UnsafeMutablePointer] = [] + defer { freeAll(&outputs) } + for p in packets { outputs.append(contentsOf: try bridge.feed(packet: p)) } + #expect(!outputs.isEmpty, "precondition: this bridge produced audio") + + let engine = makeEngine() + engine.audioBridge = bridge + engine.muxerFailureReviveGate = MuxerFailureReviveGate(maxAttempts: 0) + let surfaced = SurfacedFailure() + engine.onVODSourceFailed = { code, reason, kind in surfaced.set(code, reason, kind) } + + engine.handleVODMuxerFailure() + + #expect(surfaced.snapshot?.kind == .vodSourceFailed, + "a bridge that produced audio is not the reason the moov could not be written") + #expect(surfaced.snapshot?.reason == "Source audio cannot be muxed") + } +} diff --git a/docs/api.md b/docs/api.md index 65842bf5..82dea55b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -394,7 +394,7 @@ All flags default to safe values; the table is the full set. Depth for the media | `AudioTapBuffer` | `buffer` (`AVAudioPCMBuffer`), `sourceTime`, `discontinuity`. Non-discontinuity buffers are strictly increasing and non-overlapping, which is what SpeechAnalyzer's input timeline requires. | | `LiveTelemetry` | The 1 Hz snapshot: bitrates, observed fps, dropped frames, cache and network bytes, A/V gap, RSS. | | `PlaybackErrorInfo` | `kind`, `underlyingDomain`, `underlyingCode`, `message`. Published as `$errorInfo` beside a `.error` state. | -| `PlaybackErrorKind` | The stable token inside it: `.sourceOpenFailed`, `.sourceRefused` (the origin answered an HTTP status other than a rate limit instead of media; `underlyingCode` is the status), `.customSourceProbeFailed`, `.liveSourceUnavailable`, `.hlsPlaylistOnRawLivePath`, `.dolbyVisionRequiresHardware`, `.demuxedAudioLiveUnsupported`, `.nativeItemFailed`, `.noPlayableTrackWithinBudget`, `.masterPlaylistRejected`, `.vodSourceFailed`, `.sourceRateLimited`, `.softwarePipelineFailed`, `.audioSessionFailed`, `.reloadFailed`, `.liveReloadNeverReady`, `.audioTrackSwitchFailed`. `.sourceRateLimited` is the one to branch on separately: the source is being metered, not lost, so the same request is expected to work later and a handoff to a second player will meet the same refusal (AE#377). A string-backed struct rather than an enum, so a kind added in a minor release cannot break a host's switch; raw values are API and do not change. | +| `PlaybackErrorKind` | The stable token inside it: `.sourceOpenFailed`, `.sourceRefused` (the origin answered an HTTP status other than a rate limit instead of media; `underlyingCode` is the status), `.customSourceProbeFailed`, `.liveSourceUnavailable`, `.hlsPlaylistOnRawLivePath`, `.dolbyVisionRequiresHardware`, `.demuxedAudioLiveUnsupported`, `.nativeItemFailed`, `.noPlayableTrackWithinBudget`, `.masterPlaylistRejected`, `.vodSourceFailed`, `.sourceRateLimited`, `.softwarePipelineFailed`, `.audioSessionFailed`, `.reloadFailed`, `.liveReloadNeverReady`, `.audioTrackSwitchFailed`, `.audioBridgeProducedNoOutput`. `.sourceRateLimited` is the one to branch on separately: the source is being metered, not lost, so the same request is expected to work later and a handoff to a second player will meet the same refusal (AE#377). `.audioBridgeProducedNoOutput` is the other: a source whose audio has to be transcoded into fMP4 (MP3, MP2, DTS, TrueHD, Vorbis, PCM) produced no encoded audio at all, so the mp4 muxer could not build the sample entry it derives from a written packet (AE#396). It used to arrive as `.vodSourceFailed`, which reads as a dead source and ends a fallback ladder; the source is neither gone nor unreadable here, and a second player that decodes the track itself plays the file, so this is a DEMOTE, not a stop. A string-backed struct rather than an enum, so a kind added in a minor release cannot break a host's switch; raw values are API and do not change. | | `DisplayCapabilities`, `StartupProgress`, `SeekEvent`, `PresentationAxisMap`, `NativeVideoFrameTime`, `SoftwareVideoFrameTime`, `SoftwarePiPSource`, `SystemCaptionRequest`, `AetherEngineError`, `HLSIngestError` | Covered in their sections above. | | `FontAttachment` | Attached font files for authored ASS rendering: `filename`, `mimeType`, `data`. |