739a5f76bf
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 58s
decky / build-publish (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 43s
ci / bench (push) Successful in 5m21s
arch / build-publish (push) Successful in 10m58s
docker / deploy-docs (push) Successful in 28s
android / android (push) Successful in 13m23s
deb / build-publish (push) Successful in 12m58s
ci / rust (push) Successful in 18m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m56s
flatpak / build-publish (push) Successful in 5m53s
windows-host / package (push) Successful in 14m37s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m12s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m6s
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
release / apple (push) Has been cancelled
The Apple client now decodes PyroWave natively on the presenter's own MTLDevice —
no MoltenVK, no upstream C++ in the app. Completes and wires up the decoder whose
early working-tree snapshot rode along in 9127c346:
- MetalWaveletShaders.swift: wavelet_dequant + idwt hand-ported from the vendored
GLSL (STORAGE_MODE 0 only; subgroup scans → 32-wide simdgroups; DCShift spec
constant → function constant; precision-1 split: fp16 levels 0-1 / fp32 2-4).
- MetalWaveletDecoder.swift: Swift reimplementation of push_packet/decode_packet
incl. the Phase-4 chunk-aligned window walk (FRAG chains, zeroed missing shards,
the >half-blocks partial rule), init_block_meta's block-index space, and the
42-dequant + 13-idwt dispatch structure with encoder-boundary barriers. SOF-dims
changes rebuild the size-dependent resources, which is also the mid-stream
resize path. Ring of 4 output plane sets on the presenter's queue.
- Presenter: pf_frag_planar (3xR8, the planar_csc.frag twin) + renderPlanar with
a shared present tail; ReadyFrame carries an image enum (.video | .planar).
- Stage2Pipeline: a dedicated PyroWave pump — no VideoToolbox machinery, no
keyframe/re-anchor recovery (all-intra; partials render as localized blur by
design), newest-frame-index staleness guard for late partials.
- Opt-in: "PyroWave (wired LAN)" codec entry (probe-gated, ≈A13 floor via a real
kernel-compile probe), selecting it advertises + prefers the codec and forces
the session SDR (HDR/10-bit/4:4:4 caps dropped, plan contract).
- Core ABI: punktfunk_connection_shard_payload() — the Welcome's negotiated shard
payload, needed by native decoders to walk chunk-aligned AUs.
- Validation: golden fixtures generated by the host encoder + upstream's own
decoder (pyrowave_dump_golden, RTX 5070 Ti); the Metal decode PSNR-matches at
77-88 dB across all planes for dense AND chunk-aligned AUs, and a hole-punched
partial still decodes. Parser unit tests cover the window walk, FRAG chains,
broken chains, the half-blocks gate, and the block-index layout.
Tests: apple 134 green (mac; iOS/tvOS build), host 312 w/ pyrowave on .21,
core 148 w/ quic; clippy/fmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
293 lines
14 KiB
Swift
293 lines
14 KiB
Swift
// PyroWave Metal decoder tests — two layers:
|
||
//
|
||
// 1. Bitstream/window-walk parser tests (pure CPU): hand-crafted packet streams assert the
|
||
// exact wire semantics of pyrowave_decoder.cpp's push_packet walk + the Phase-4
|
||
// chunk-aligned framing (4-byte window prefix, FRAG chains, zeroed missing shards).
|
||
//
|
||
// 2. Golden-frame PSNR tests (Metal GPU): host-encoded fixtures (crates/punktfunk-host
|
||
// encode/linux/pyrowave.rs `pyrowave_dump_golden`, run on a Vulkan box) decoded by the
|
||
// Metal port and PSNR-matched against upstream's own decoder output. Float wavelet math is
|
||
// not bit-exact across implementations (upstream ships precision variants), so the gate is
|
||
// PSNR, not equality. This is the §4.7 validation oracle for the hand-ported kernels —
|
||
// the gather/mirror addressing in idwt is the spot most likely to drift.
|
||
|
||
#if canImport(Metal)
|
||
import Metal
|
||
import XCTest
|
||
|
||
@testable import PunktfunkKit
|
||
|
||
final class PyroWaveParserTests: XCTestCase {
|
||
// 256x144 → aligned 256x160; block space identical to the committed fixtures.
|
||
private let width = 256
|
||
private let height = 144
|
||
|
||
/// A BitstreamSequenceHeader (START_OF_FRAME) for `width`x`height`, 4:2:0 BT.709 limited.
|
||
private func sof(totalBlocks: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||
let word0 =
|
||
UInt32(width - 1) | (UInt32(height - 1) << 14) | (sequence << 28) | (1 << 31)
|
||
// code=0 (SOF), chroma=0 (420), primaries/trc/matrix=0 (BT.709), range=1 (LIMITED),
|
||
// siting=0.
|
||
let word1 = UInt32(totalBlocks) | (1 << 30)
|
||
return le32(word0) + le32(word1)
|
||
}
|
||
|
||
/// A minimal coefficient packet: ballot=0 (all 8x8 blocks empty — legal and decodable),
|
||
/// payload_words=2 (header only).
|
||
private func packet(blockIndex: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||
let word0 = UInt32(0) | (2 << 16) | (sequence << 28)
|
||
let word1 = UInt32(0) | (UInt32(blockIndex) << 8)
|
||
return le32(word0) + le32(word1)
|
||
}
|
||
|
||
private func le32(_ v: UInt32) -> [UInt8] {
|
||
[UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8(v >> 24)]
|
||
}
|
||
|
||
/// Wrap bodies into `windowSize`-sized windows with the 4-byte used/kind prefix.
|
||
private func window(_ body: [UInt8], kind: UInt16, size: Int) -> [UInt8] {
|
||
precondition(body.count + 4 <= size)
|
||
var out = [UInt8(body.count & 0xff), UInt8(body.count >> 8)]
|
||
out += [UInt8(kind & 0xff), UInt8(kind >> 8)]
|
||
out += body
|
||
out += [UInt8](repeating: 0, count: size - out.count)
|
||
return out
|
||
}
|
||
|
||
func testLayoutMatchesUpstreamBlockSpace() {
|
||
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
||
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
|
||
let layout = WaveletLayout(width: width, height: height)
|
||
XCTAssertEqual(layout.alignedWidth, 256)
|
||
XCTAssertEqual(layout.alignedHeight, 160)
|
||
XCTAssertEqual(layout.levelWidth(0), 128)
|
||
XCTAssertEqual(layout.levelHeight(0), 80)
|
||
XCTAssertEqual(layout.levelWidth(4), 8)
|
||
XCTAssertEqual(layout.levelHeight(4), 5)
|
||
// Hand-summed: L4 (8x5 → 1 block) × 3 comps × 4 bands = 12; L3 (16x10 → 1) × 9 = 9;
|
||
// L2 (32x20 → 1) × 9 = 9; L1 (64x40 → 2x2=4... ) — trust the invariant instead:
|
||
// every band's count is ceil(w8/4)*ceil(h8/4) and the total is their sum.
|
||
var expected = 0
|
||
for level in stride(from: 4, through: 0, by: -1) {
|
||
let w8 = (layout.levelWidth(level) + 7) / 8
|
||
let h8 = (layout.levelHeight(level) + 7) / 8
|
||
let per = ((w8 + 3) / 4) * ((h8 + 3) / 4)
|
||
for component in 0..<3 {
|
||
if level == 0 && component != 0 { continue }
|
||
expected += per * (level == 4 ? 4 : 3)
|
||
}
|
||
}
|
||
XCTAssertEqual(layout.blockCount32, expected)
|
||
// The finest luma level's stride is its 32-block row width.
|
||
XCTAssertEqual(layout.blockMeta[0][0][1].stride, (128 + 31) / 32)
|
||
// Level-0 chroma is not coded in 4:2:0.
|
||
XCTAssertEqual(layout.blockMeta[1][0][1].offset, -1)
|
||
}
|
||
|
||
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
||
let layout = WaveletLayout(width: width, height: height)
|
||
var au = sof(totalBlocks: 4)
|
||
au += packet(blockIndex: 0)
|
||
au += packet(blockIndex: 3)
|
||
au += packet(blockIndex: 3) // duplicate — first wins, not double-counted
|
||
au += packet(blockIndex: layout.blockCount32 - 1)
|
||
let frame = try XCTUnwrap(
|
||
WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||
XCTAssertEqual(frame.layout.width, width)
|
||
XCTAssertEqual(frame.totalBlocks, 4)
|
||
XCTAssertEqual(frame.decodedBlocks, 3)
|
||
XCTAssertEqual(frame.offsets[0], 0)
|
||
XCTAssertEqual(frame.offsets[3], 2) // u32 words: each header-only packet is 2 words
|
||
XCTAssertEqual(frame.offsets[1], UInt32.max)
|
||
XCTAssertEqual(frame.payload.count, 6)
|
||
XCTAssertFalse(frame.bt2020)
|
||
XCTAssertFalse(frame.fullRange) // range bit 1 = LIMITED
|
||
}
|
||
|
||
func testHalfOrFewerBlocksIsDropped() {
|
||
var au = sof(totalBlocks: 4)
|
||
au += packet(blockIndex: 0)
|
||
au += packet(blockIndex: 1)
|
||
// 2 of 4 decoded = exactly half — upstream requires MORE than half.
|
||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||
}
|
||
|
||
func testMissingSOFIsDropped() {
|
||
let au = packet(blockIndex: 0) + packet(blockIndex: 1)
|
||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||
}
|
||
|
||
func testTruncatedPacketIsRejected() {
|
||
var au = sof(totalBlocks: 1)
|
||
// Claims 4 payload words but only the 8-byte header follows.
|
||
let word0 = UInt32(0) | (4 << 16) | (1 << 28)
|
||
au += le32(word0) + le32(0)
|
||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||
}
|
||
|
||
func testWindowWalkPackedFragAndMissingShard() throws {
|
||
let size = 64
|
||
// Window 1: SOF + one packet, PACKED. Window 2: a FRAG chain carrying one packet split
|
||
// across two windows. Window 3: all zeros (a lost shard of a partial frame). Window 4:
|
||
// a PACKED packet — the chain break must not eat it.
|
||
let fragPacket = packet(blockIndex: 2)
|
||
var au = window(sof(totalBlocks: 3) + packet(blockIndex: 0), kind: 0, size: size)
|
||
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||
au += window(Array(fragPacket[5...]), kind: 3, size: size)
|
||
au += [UInt8](repeating: 0, count: size) // missing shard
|
||
au += window(packet(blockIndex: 1), kind: 0, size: size)
|
||
let frame = try XCTUnwrap(
|
||
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||
XCTAssertEqual(frame.decodedBlocks, 3)
|
||
XCTAssertEqual(frame.offsets[0], 0)
|
||
XCTAssertEqual(frame.offsets[2], 2)
|
||
XCTAssertEqual(frame.offsets[1], 4)
|
||
}
|
||
|
||
func testBrokenFragChainIsDiscarded() throws {
|
||
let size = 64
|
||
let fragPacket = packet(blockIndex: 2)
|
||
var au = window(sof(totalBlocks: 1) + packet(blockIndex: 0), kind: 0, size: size)
|
||
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||
au += [UInt8](repeating: 0, count: size) // the chain's middle shard was lost
|
||
au += window(Array(fragPacket[5...]), kind: 3, size: size) // dangling LAST — dropped
|
||
let frame = try XCTUnwrap(
|
||
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||
XCTAssertEqual(frame.decodedBlocks, 1)
|
||
XCTAssertEqual(frame.offsets[2], UInt32.max)
|
||
}
|
||
}
|
||
|
||
/// Golden-frame decode against the committed host-encoder fixtures. Skipped when the machine
|
||
/// has no Metal device (headless CI) — everywhere else this is the hand-ported kernels' guard.
|
||
final class PyroWaveGoldenTests: XCTestCase {
|
||
private static let fixtureDir = "PyroWaveFixtures"
|
||
|
||
private func fixture(_ name: String) throws -> Data {
|
||
let url = try XCTUnwrap(
|
||
Bundle.module.url(
|
||
forResource: name, withExtension: "bin", subdirectory: Self.fixtureDir),
|
||
"missing fixture \(name).bin — regenerate with pyrowave_dump_golden")
|
||
return try Data(contentsOf: url)
|
||
}
|
||
|
||
/// Completion box — the decode callback lands on a Metal thread.
|
||
private final class ResultBox: @unchecked Sendable {
|
||
let lock = NSLock()
|
||
var planes: WaveletPlanes?
|
||
}
|
||
|
||
/// Decode `au` synchronously and read all three planes back to CPU bytes.
|
||
private func decode(
|
||
au: Data, chunkAligned: Bool, windowSize: Int
|
||
) throws -> (y: [UInt8], cb: [UInt8], cr: [UInt8]) {
|
||
let device = try XCTUnwrap(MTLCreateSystemDefaultDevice())
|
||
let queue = try XCTUnwrap(device.makeCommandQueue())
|
||
let decoder = try XCTUnwrap(MetalWaveletDecoder(device: device, queue: queue))
|
||
let done = expectation(description: "decode completes")
|
||
let box = ResultBox()
|
||
let submitted = decoder.decode(
|
||
au: au, chunkAligned: chunkAligned, windowSize: windowSize
|
||
) { planes in
|
||
box.lock.lock()
|
||
box.planes = planes
|
||
box.lock.unlock()
|
||
done.fulfill()
|
||
}
|
||
XCTAssertTrue(submitted, "the fixture AU must parse")
|
||
wait(for: [done], timeout: 10)
|
||
box.lock.lock()
|
||
let result = box.planes
|
||
box.lock.unlock()
|
||
let planes = try XCTUnwrap(result, "the GPU pass must complete without error")
|
||
return (
|
||
try readback(planes.y, device: device, queue: queue),
|
||
try readback(planes.cb, device: device, queue: queue),
|
||
try readback(planes.cr, device: device, queue: queue)
|
||
)
|
||
}
|
||
|
||
private func readback(
|
||
_ texture: MTLTexture, device: MTLDevice, queue: MTLCommandQueue
|
||
) throws -> [UInt8] {
|
||
let bytesPerRow = texture.width
|
||
let length = bytesPerRow * texture.height
|
||
let buffer = try XCTUnwrap(device.makeBuffer(length: length, options: .storageModeShared))
|
||
let cmd = try XCTUnwrap(queue.makeCommandBuffer())
|
||
let blit = try XCTUnwrap(cmd.makeBlitCommandEncoder())
|
||
blit.copy(
|
||
from: texture, sourceSlice: 0, sourceLevel: 0,
|
||
sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0),
|
||
sourceSize: MTLSize(width: texture.width, height: texture.height, depth: 1),
|
||
to: buffer, destinationOffset: 0, destinationBytesPerRow: bytesPerRow,
|
||
destinationBytesPerImage: length)
|
||
blit.endEncoding()
|
||
cmd.commit()
|
||
cmd.waitUntilCompleted()
|
||
return [UInt8](UnsafeRawBufferPointer(start: buffer.contents(), count: length))
|
||
}
|
||
|
||
private func psnr(_ a: [UInt8], _ b: [UInt8]) -> Double {
|
||
precondition(a.count == b.count)
|
||
var sse = 0.0
|
||
for i in 0..<a.count {
|
||
let d = Double(a[i]) - Double(b[i])
|
||
sse += d * d
|
||
}
|
||
if sse == 0 { return .infinity }
|
||
let mse = sse / Double(a.count)
|
||
return 10 * log10(255.0 * 255.0 / mse)
|
||
}
|
||
|
||
private func assertMatchesReference(
|
||
_ decoded: (y: [UInt8], cb: [UInt8], cr: [UInt8]), prefix: String,
|
||
file: StaticString = #filePath, line: UInt = #line
|
||
) throws {
|
||
for (name, plane, ref) in [
|
||
("y", decoded.y, try fixture("\(prefix)-y")),
|
||
("cb", decoded.cb, try fixture("\(prefix)-cb")),
|
||
("cr", decoded.cr, try fixture("\(prefix)-cr")),
|
||
] {
|
||
XCTAssertEqual(plane.count, ref.count, file: file, line: line)
|
||
let db = psnr(plane, [UInt8](ref))
|
||
print("pyrowave golden \(prefix) \(name): \(db) dB")
|
||
// The Metal port and upstream's decoder run the same math at the same precision
|
||
// tier; residual differences are float rounding + the gather/mirror edge handling.
|
||
// Well-matched ports measure ≫50 dB; 45 catches a real divergence long before it
|
||
// is visible.
|
||
XCTAssertGreaterThan(db, 45.0, "plane PSNR \(db) dB", file: file, line: line)
|
||
}
|
||
}
|
||
|
||
func testDenseGoldenFrame() throws {
|
||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||
let au = try fixture("au-dense")
|
||
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
|
||
try assertMatchesReference(decoded, prefix: "ref-dense")
|
||
}
|
||
|
||
func testChunkAlignedGoldenFrame() throws {
|
||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||
let au = try fixture("au-chunked")
|
||
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
||
}
|
||
|
||
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) — the frame must still
|
||
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
||
/// localized blur, not garbage).
|
||
func testPartialFrameStillDecodes() throws {
|
||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||
var au = try fixture("au-chunked")
|
||
let windows = au.count / 1408
|
||
try XCTSkipIf(windows < 3, "fixture too small to punch a hole in")
|
||
let hole = (windows / 2) * 1408
|
||
au.replaceSubrange(hole..<(hole + 1408), with: [UInt8](repeating: 0, count: 1408))
|
||
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||
let ref = try fixture("ref-chunked-y")
|
||
let db = psnr(decoded.y, [UInt8](ref))
|
||
XCTAssertGreaterThan(db, 25.0, "lossy frame should still resemble the source (\(db) dB)")
|
||
}
|
||
}
|
||
#endif
|