feat(apple): PyroWave Phase 5 — native Metal decode on Mac / Apple TV / iPad (§4.7)
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>
This commit is contained in:
2026-07-15 12:16:43 +02:00
parent a70811043e
commit 739a5f76bf
28 changed files with 826 additions and 81 deletions
@@ -183,18 +183,12 @@ enum WaveletBitstream {
}
return state.pushPackets(UnsafeBufferPointer(start: base, count: count))
}
guard ok, var frame = state.finish() else { return nil }
guard ok, let frame = state.finish() else { return nil }
// Upstream decode_is_ready(allow_partial=true): with no SOF the frame is undecodable;
// at half the blocks or fewer it is presumed garbage.
guard frame.totalBlocks > 0, frame.decodedBlocks > frame.totalBlocks / 2 else {
return nil
}
// The dequant kernel indexes the offset table by the LAYOUT's block space; the wire's
// total_blocks only counts blocks the encoder emitted. They agree for a full-coverage
// frame, but size the table by the layout.
if frame.offsets.count != frame.layout.blockCount32 {
frame.offsets = Array(frame.offsets.prefix(frame.layout.blockCount32))
}
return frame
}
@@ -293,17 +287,17 @@ enum WaveletBitstream {
/// One decoded frame's output planes, handed to the presenter's planar render path. The
/// textures belong to the decoder's ring ring depth (4) plus same-queue hazard tracking keep
/// them valid while referenced.
struct WaveletPlanes {
let y: MTLTexture
let cb: MTLTexture
let cr: MTLTexture
let csc: CscUniform
var width: Int { y.width }
var height: Int { y.height }
/// them valid while referenced. Public because it rides inside `ReadyImage`.
public struct WaveletPlanes: @unchecked Sendable {
public let y: MTLTexture
public let cb: MTLTexture
public let cr: MTLTexture
public let csc: CscUniform
public var width: Int { y.width }
public var height: Int { y.height }
}
final class MetalWaveletDecoder {
public final class MetalWaveletDecoder {
/// Matches the Vulkan client's ring: deep enough that a slot is never rewritten while the
/// presenter still samples it in practice; same-queue hazard tracking is the hard backstop.
private static let ringDepth = 4
@@ -312,7 +306,7 @@ final class MetalWaveletDecoder {
/// dequant kernel needs simdgroup prefix sums with its 16 header lanes inside one
/// simdgroup, so compile the real kernels once and check the pipeline facts. Apple6 (A13)
/// and every Mac2 device pass the family check; the compile probe is authoritative.
static let supported: Bool = {
public static let supported: Bool = {
guard let device = MTLCreateSystemDefaultDevice() else { return false }
guard device.supportsFamily(.apple6) || device.supportsFamily(.mac2) else { return false }
do {
@@ -358,6 +352,12 @@ final class MetalWaveletDecoder {
private var slots: [Slot] = []
private var nextSlot = 0
/// The current geometry (from the last SOF that built the resources) the pump reports
/// decoded-size changes to the resize overlay from this. PUMP THREAD.
var decodedSize: (width: Int, height: Int)? {
layout.map { ($0.width, $0.height) }
}
/// The pump thread owns `decode`; everything mutable is confined to it.
init?(device: MTLDevice, queue: MTLCommandQueue) {
self.device = device