diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift index 1afa0f18..fe75d522 100644 --- a/clients/apple/Package.swift +++ b/clients/apple/Package.swift @@ -42,6 +42,13 @@ let package = Package( .executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]), // PunktfunkCore is a direct dep too so the wire tests can name the C ABI's // `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout. - .testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"]), + .testTarget( + name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"], + resources: [ + // PyroWave golden fixtures: host-encoded AUs + upstream-decoded reference + // planes (regenerate with punktfunk-host's `pyrowave_dump_golden` on a + // Vulkan box — see PyroWaveDecoderTests.swift). + .copy("PyroWaveFixtures") + ]), ] ) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 61020f82..32121f58 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -46,6 +46,7 @@ struct ContentView: View { case "h264": return PunktfunkConnection.codecH264 case "hevc": return PunktfunkConnection.codecHEVC case "av1": return PunktfunkConnection.codecAV1 + case "pyrowave": return PunktfunkConnection.codecPyroWave default: return 0 } } diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index cd951d3a..79c5d56d 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -239,6 +239,18 @@ final class SessionModel: ObservableObject { // from these + the soft `preferredCodec`; `resolvedCodec` reflects what it chose. var videoCodecs = PunktfunkConnection.codecH264 | PunktfunkConnection.codecHEVC if AV1.hardwareDecodeSupported { videoCodecs |= PunktfunkConnection.codecAV1 } + // PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both + // advertises the bit and prefers it — the host never auto-selects it, and the + // picker only offers it when the Metal decode probe passed (simdgroup floor ≈ A13; + // every M-series Mac and the ATV 4K gen 3 pass). The codec is 8-bit 4:2:0 SDR + // BT.709 by contract, so the opt-in also drops the HDR/10-bit/4:4:4 caps for this + // session — HDR sessions stay HEVC/AV1 (plan §4.7). + if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported { + videoCodecs |= PunktfunkConnection.codecPyroWave + videoCaps &= ~(PunktfunkConnection.videoCap10Bit + | PunktfunkConnection.videoCapHDR + | PunktfunkConnection.videoCap444) + } let result = Result { try PunktfunkConnection( host: host.address, port: host.port, width: width, height: height, refreshHz: hz, diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index 22286142..de0b8657 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -79,6 +79,13 @@ enum SettingsOptions { if AV1.hardwareDecodeSupported { options.insert(("AV1", "av1"), at: 2) } + // PyroWave is the opt-in wired-LAN low-latency codec (100–400 Mbps all-intra wavelet, + // 8-bit SDR): selecting it advertises + prefers it for the session. Offered only when + // the Metal decode probe passes (same gate SessionModel advertises by) — elsewhere the + // host could never emit it. + if MetalWaveletDecoder.supported { + options.append(("PyroWave (wired LAN)", "pyrowave")) + } return options }() diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 61e42f8b..8adc5955 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -337,9 +337,15 @@ public final class PunktfunkConnection { public private(set) var resolvedAudioChannels: UInt8 = 2 /// The video codec the host resolved for this session (`Welcome.codec`, `PUNKTFUNK_CODEC_*`): - /// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1. Build the decoder from THIS. The - /// resolved value honors the client's `preferredCodec` when the host could emit it. + /// `2` = HEVC (default / older host), `1` = H.264, `4` = AV1, `8` = PyroWave (only when this + /// client opted in). Build the decoder from THIS. The resolved value honors the client's + /// `preferredCodec` when the host could emit it. public private(set) var resolvedCodec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC + + /// The session's negotiated wire shard payload (`Welcome.shard_payload`, bytes) — the + /// parse-window size for `USER_FLAG_CHUNK_ALIGNED` PyroWave AUs (plan §4.4). Other codecs + /// never need it. + public private(set) var shardPayload: UInt32 = 1408 /// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing /// (Annex-B NAL parsing vs the AV1 OBU repack). public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) } @@ -452,6 +458,9 @@ public final class PunktfunkConnection { var codec: UInt8 = 2 // PUNKTFUNK_CODEC_HEVC _ = punktfunk_connection_codec(handle, &codec) resolvedCodec = codec + var shard: UInt32 = 1408 + _ = punktfunk_connection_shard_payload(handle, &shard) + shardPayload = shard } /// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`. @@ -790,6 +799,15 @@ public final class PunktfunkConnection { public static let codecH264: UInt8 = UInt8(PUNKTFUNK_CODEC_H264) public static let codecHEVC: UInt8 = UInt8(PUNKTFUNK_CODEC_HEVC) public static let codecAV1: UInt8 = UInt8(PUNKTFUNK_CODEC_AV1) + /// PyroWave (opt-in wired-LAN wavelet codec, 8-bit SDR): the host only ever resolves it + /// when the client both advertises the bit AND names it `preferredCodec` — never + /// auto-selected. Decoded by the Metal wavelet decoder, not VideoToolbox. + public static let codecPyroWave: UInt8 = UInt8(PUNKTFUNK_CODEC_PYROWAVE) + + /// `AccessUnit.flags` bit: the AU is shard-aligned self-delimiting chunks (the wire's + /// `USER_FLAG_CHUNK_ALIGNED`, PyroWave datagram-aligned mode §4.4) — walk it + /// window-by-window at `shardPayload`. (The C `#define` doesn't import into Swift.) + public static let userFlagChunkAligned: UInt32 = 64 /// Static HDR mastering metadata (SMPTE ST.2086 + content light level) the host sent for an HDR /// session. Mirrors the wire/ABI `PunktfunkHdrMeta`; primaries are in ST.2086 **G, B, R** order, diff --git a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift index adbc5210..5b94cbba 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift @@ -27,8 +27,10 @@ public enum DefaultsKey { /// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it /// can capture; the resolved count drives the in-core decode + AVAudioEngine layout. public static let audioChannels = "punktfunk.audioChannels" - /// Preferred video codec: `"auto"` (host decides), `"hevc"`, or `"h264"`. A soft preference — - /// the host emits it when it can, else falls back. Drives the decoder via `Welcome.codec`. + /// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, `"av1"`, or + /// `"pyrowave"` (the opt-in wired-LAN wavelet codec — picking it advertises AND prefers it, + /// and forces the session SDR). A soft preference — the host emits it when it can, else + /// falls back. Drives the decoder via `Welcome.codec`. public static let codec = "punktfunk.codec" public static let micEnabled = "punktfunk.micEnabled" public static let speakerUID = "punktfunk.speakerUID" diff --git a/clients/apple/Sources/PunktfunkKit/Video/AV1.swift b/clients/apple/Sources/PunktfunkKit/Video/AV1.swift index cb0cc9c7..32050b36 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/AV1.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/AV1.swift @@ -543,19 +543,24 @@ public enum AV1 { extension VideoCodec { /// Codec-dispatching format-description refresh: the AV1 path keys on an in-band sequence - /// header, the NAL codecs on in-band parameter sets — one call site in each pump. + /// header, the NAL codecs on in-band parameter sets — one call site in each pump. PyroWave + /// has no CoreMedia representation at all (its pump feeds the Metal wavelet decoder raw). public func formatDescription(fromKeyframe au: Data) -> CMVideoFormatDescription? { - self == .av1 - ? AV1.formatDescription(fromKeyframe: au) - : AnnexB.formatDescription(fromIDR: au, codec: self) + switch self { + case .av1: return AV1.formatDescription(fromKeyframe: au) + case .pyrowave: return nil + default: return AnnexB.formatDescription(fromIDR: au, codec: self) + } } /// Codec-dispatching sample wrap (see `formatDescription(fromKeyframe:)`). public func sampleBuffer( au: AccessUnit, format: CMVideoFormatDescription ) -> CMSampleBuffer? { - self == .av1 - ? AV1.sampleBuffer(au: au, format: format) - : AnnexB.sampleBuffer(au: au, format: format, codec: self) + switch self { + case .av1: return AV1.sampleBuffer(au: au, format: format) + case .pyrowave: return nil + default: return AnnexB.sampleBuffer(au: au, format: format, codec: self) + } } } diff --git a/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift b/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift index 303e7a8d..0b2821c1 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift @@ -26,12 +26,18 @@ public enum VideoCodec: Equatable { case h264 case hevc case av1 + /// PyroWave wavelet (opt-in wired-LAN low-latency codec): not a NAL/OBU codec and not + /// VideoToolbox-decoded at all — the Metal wavelet decoder consumes the raw AUs + /// (Stage2Pipeline's PyroWave pump). Only ever resolved when this client both advertised + /// and preferred it. + case pyrowave /// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC). public init(wire: UInt8) { switch wire { case 0x01: self = .h264 // PUNKTFUNK_CODEC_H264 case 0x04: self = .av1 // PUNKTFUNK_CODEC_AV1 + case 0x08: self = .pyrowave // PUNKTFUNK_CODEC_PYROWAVE default: self = .hevc // PUNKTFUNK_CODEC_HEVC — the default / older-host codec } } @@ -147,8 +153,8 @@ public enum AnnexB { sets = [vps, sps, pps] case .h264: sets = [sps, pps] - case .av1: - return nil // OBU stream, no parameter-set NALs — handled in AV1.swift, never here + case .av1, .pyrowave: + return nil // no parameter-set NALs — dispatched in AV1.swift, never reaches here } var format: CMVideoFormatDescription? @@ -184,8 +190,8 @@ public enum AnnexB { parameterSetSizes: sizes, nalUnitHeaderLength: 4, formatDescriptionOut: &format) - case .av1: - break // unreachable — the .av1 arm above already returned + case .av1, .pyrowave: + break // unreachable — the arm above already returned } } return status == noErr ? format : nil diff --git a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift index 25a81d4a..dc2a587e 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift @@ -149,6 +149,28 @@ fragment float4 pf_frag(VOut in [[stage_in]], return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0); } +// PyroWave planar SDR: three separate R8 planes (Y full-res, Cb/Cr half-res 4:2:0) from the +// Metal wavelet decoder — the Metal twin of pf-presenter's planar_csc.frag. Same bicubic luma +// and left-cosited chroma correction as the biplanar path (chromaUV self-disables at 4:4:4). +fragment float4 pf_frag_planar(VOut in [[stage_in]], + texture2d lumaTex [[texture(0)]], + texture2d cbTex [[texture(1)]], + texture2d crTex [[texture(2)]], + constant CscUniform& csc [[buffer(0)]]) { + constexpr sampler s(filter::linear, address::clamp_to_edge); +#ifdef PF_BILINEAR_LUMA + float lumaY = lumaTex.sample(s, in.uv).r; +#else + float lumaY = catmullRomLuma(lumaTex, s, in.uv); +#endif + float2 cuv = chromaUV(lumaTex, cbTex, in.uv); + float3 yuv = float3(lumaY, cbTex.sample(s, cuv).r, crTex.sample(s, cuv).r); + float3 rgb = saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w, + dot(csc.r1.xyz, yuv) + csc.r1.w, + dot(csc.r2.xyz, yuv) + csc.r2.w)); + return float4(rgb, 1.0); +} + // HDR: 10-bit P010 / 4:4:4 (BT.2020, PQ-encoded Y′CbCr) → full-range PQ R′G′B′, output as-is — // the CAMetalLayer's itur_2100_PQ colour space + edrMetadata tell the compositor the samples are // PQ, so it does the PQ→display tone-map. No EOTF here. The rows fold in the exact 10-bit @@ -215,8 +237,16 @@ public final class MetalVideoPresenter { /// tvOS only: the in-shader PQ→SDR tone-map fallback (pf_frag_hdr_tv → bgra8), used whenever /// the display is composited without HDR headroom — see `setDisplayHeadroom`. nil elsewhere. private let pipelineHDRToneMap: MTLRenderPipelineState? + /// PyroWave's 3-plane SDR path (pf_frag_planar → bgra8) — see `renderPlanar`. + private let pipelinePlanar: MTLRenderPipelineState private var textureCache: CVMetalTextureCache? + /// The PyroWave Metal decoder records on the presenter's device + queue: one device means + /// decode, CSC and present share textures with zero interop, and one queue means Metal's + /// hazard tracking orders a ring-slot rewrite after the render still sampling it. + var metalDevice: MTLDevice { device } + var metalQueue: MTLCommandQueue { queue } + /// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs. /// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread /// `configure` call is ordered before the thread starts, so it doesn't race). @@ -258,6 +288,7 @@ public final class MetalVideoPresenter { let pipelineSDR: MTLRenderPipelineState let pipelineHDR: MTLRenderPipelineState let pipelineHDRToneMap: MTLRenderPipelineState? + let pipelinePlanar: MTLRenderPipelineState do { // DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF // (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is @@ -292,6 +323,11 @@ public final class MetalVideoPresenter { #else pipelineHDRToneMap = nil #endif + let planar = MTLRenderPipelineDescriptor() + planar.vertexFunction = vtx + planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar") + planar.colorAttachments[0].pixelFormat = .bgra8Unorm // PyroWave is 8-bit SDR + pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar) } catch { return nil } @@ -331,12 +367,14 @@ public final class MetalVideoPresenter { return MetalVideoPresenter( device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR, - pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer) + pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar, + textureCache: textureCache, layer: layer) } private init( device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState, pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?, + pipelinePlanar: MTLRenderPipelineState, textureCache: CVMetalTextureCache, layer: CAMetalLayer ) { self.device = device @@ -344,6 +382,7 @@ public final class MetalVideoPresenter { self.pipelineSDR = pipelineSDR self.pipelineHDR = pipelineHDR self.pipelineHDRToneMap = pipelineHDRToneMap + self.pipelinePlanar = pipelinePlanar self.textureCache = textureCache self.layer = layer } @@ -514,6 +553,67 @@ public final class MetalVideoPresenter { pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache) else { return false } + #if os(tvOS) + // HDR splits by the display's headroom (kept in step with the layer by `configure` above): + // PQ passthrough into an HDR-composited display, the tone-map shader otherwise. + let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR) + let pipeline = hdrActive ? hdrPipeline : pipelineSDR + #else + let pipeline = hdrActive ? pipelineHDR : pipelineSDR + #endif + let decodedSize = CGSize( + width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer)) + return encodePresent( + decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline, + presentAtMediaTime: presentAtMediaTime, onPresented: onPresented, + // Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU + // finishes sampling — releasing them at scope exit could free the backing mid-read. + keepAlive: [luma, chroma, pixelBuffer] + ) { encoder in + encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0) + encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1) + encoder.setFragmentBytes(&csc, length: MemoryLayout.stride, index: 0) + } + } + + /// Draw one PyroWave planar frame (three R8 planes off the Metal wavelet decoder) and + /// present it. RENDER THREAD, same contract as `render` — PyroWave is 8-bit SDR, so the + /// layer always takes the plain SDR config, and the CSC rows arrive precomputed from the + /// stream's own sequence-header signaling (no CVPixelBuffer to inspect). + @discardableResult + func renderPlanar( + _ planes: WaveletPlanes, + presentAtMediaTime: CFTimeInterval? = nil, + onPresented: ((Int64?) -> Void)? = nil + ) -> Bool { + stagingLock.lock() + let targetFromLayout = drawableTarget + stagingLock.unlock() + configure(hdr: false) + var csc = planes.csc + return encodePresent( + decodedSize: CGSize(width: planes.width, height: planes.height), + targetFromLayout: targetFromLayout, pipeline: pipelinePlanar, + presentAtMediaTime: presentAtMediaTime, onPresented: onPresented, + // The ring textures stay valid by ring depth; retaining them here also pins the + // slot's set until the sample completes (mirrors the biplanar keep-alive). + keepAlive: [planes.y, planes.cb, planes.cr] + ) { encoder in + encoder.setFragmentTexture(planes.y, index: 0) + encoder.setFragmentTexture(planes.cb, index: 1) + encoder.setFragmentTexture(planes.cr, index: 2) + encoder.setFragmentBytes(&csc, length: MemoryLayout.stride, index: 0) + } + } + + /// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one + /// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule + /// the present and the on-glass callback. + private func encodePresent( + decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState, + presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?, + keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void + ) -> Bool { // Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by // SessionPresenter.layout via `setDrawableTarget` — not read off the layer, whose geometry the // main thread owns) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass: @@ -522,8 +622,6 @@ public final class MetalVideoPresenter { // Before the first layout (zero target) fall back to the decoded size. drawableSize does NOT // track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change // (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine). - let decodedSize = CGSize( - width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer)) let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0) ? targetFromLayout : decodedSize if layer.drawableSize != targetSize { layer.drawableSize = targetSize } @@ -542,17 +640,8 @@ public final class MetalVideoPresenter { guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else { return false } - #if os(tvOS) - // HDR splits by the display's headroom (kept in step with the layer by `configure` above): - // PQ passthrough into an HDR-composited display, the tone-map shader otherwise. - let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR) - encoder.setRenderPipelineState(hdrActive ? hdrPipeline : pipelineSDR) - #else - encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR) - #endif - encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0) - encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1) - encoder.setFragmentBytes(&csc, length: MemoryLayout.stride, index: 0) + encoder.setRenderPipelineState(pipeline) + bind(encoder) encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) encoder.endEncoding() if let onPresented { @@ -580,9 +669,8 @@ public final class MetalVideoPresenter { } else { commandBuffer.present(drawable) } - // Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes - // sampling — releasing them at scope exit could free the backing mid-read. - commandBuffer.addCompletedHandler { _ in _ = (luma, chroma, pixelBuffer) } + // Keep the bound sources alive until the GPU finishes sampling (see the callers). + commandBuffer.addCompletedHandler { _ in _ = keepAlive } commandBuffer.commit() return true } diff --git a/clients/apple/Sources/PunktfunkKit/Video/MetalWaveletDecoder.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalWaveletDecoder.swift index a440cb19..a39d6b58 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MetalWaveletDecoder.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalWaveletDecoder.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 3d41de91..f3a09419 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -37,6 +37,7 @@ #if canImport(Metal) && canImport(QuartzCore) import AVFoundation import Foundation +import Metal import QuartzCore /// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode @@ -257,6 +258,7 @@ public final class Stage2Pipeline { /// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing). private let pacing: PresentPacing private let endToEndMeter: LatencyMeter? + private let decodeMeter: LatencyMeter? private let displayMeter: LatencyMeter? private let recovery = KeyframeRecovery() /// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0; @@ -306,6 +308,7 @@ public final class Stage2Pipeline { self.presenter = presenter self.pacing = pacing self.endToEndMeter = endToEndMeter + self.decodeMeter = decodeMeter self.displayMeter = displayMeter let ring = ring let recovery = recovery @@ -362,7 +365,21 @@ public final class Stage2Pipeline { let presenter = presenter let pumpStopped = pumpStopped let reanchorGate = gate - let thread = Thread { + // PyroWave rides a different decode half: no CMFormatDescription/VideoToolbox machinery + // (a wavelet AU has no parameter sets), no keyframe recovery or re-anchor freeze (the + // stream is all-intra and Phase 4's partial delivery WANTS lossy frames on glass as + // localized blur, not a freeze). The ready ring, render thread, pacing and meters are + // shared unchanged. + let thread: Thread + if connection.videoCodec == .pyrowave { + thread = Self.makePyroWavePump( + connection: connection, token: token, pumpStopped: pumpStopped, + ring: ring, renderSignal: renderSignal, + device: presenter.metalDevice, queue: presenter.metalQueue, + decodeMeter: decodeMeter, + onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize) + } else { + thread = Thread { defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset() var format: CMVideoFormatDescription? // Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per @@ -445,6 +462,7 @@ public final class Stage2Pipeline { } } } + } } thread.name = "punktfunk-stage2-pump" thread.qualityOfService = .userInteractive @@ -504,9 +522,7 @@ public final class Stage2Pipeline { let presentAt = vsyncEnabled ? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil let renderStarted = CACurrentMediaTime() - let rendered = presenter.render( - frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt - ) { presentedNs in + let onGlass: (Int64?) -> Void = { presentedNs in // Stage-3: the flip reached glass (or was dropped) — free the present slot, // then re-signal so the freshest waiting ring frame goes out immediately. if let gate { @@ -525,6 +541,18 @@ public final class Stage2Pipeline { displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0) debugStats?.presented(atNs: presentedNs) } + // One present tail, two decode sources: the VideoToolbox biplanar buffer or the + // PyroWave Metal planes — the ring, pacing and meters are agnostic to which. + let rendered: Bool + switch frame.image { + case .video(let pixelBuffer, let isHDR): + rendered = presenter.render( + pixelBuffer, isHDR: isHDR, presentAtMediaTime: presentAt, + onPresented: onGlass) + case .planar(let planes): + rendered = presenter.renderPlanar( + planes, presentAtMediaTime: presentAt, onPresented: onGlass) + } debugStats?.renderReturned( ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000) if !rendered { @@ -592,6 +620,93 @@ public final class Stage2Pipeline { renderSignal.signal() // wake the render thread so it can observe the stop and exit } + /// The PyroWave pump: AUs go straight into the Metal wavelet decoder (no VideoToolbox, no + /// format descriptions), decoded planes ride the same ready ring / render thread. All-intra + /// stream, so none of the VT pump's recovery machinery applies: keyframe/RFI requests are + /// silenced host-side for this codec, and a lossy (partial-delivery) frame is MEANT to + /// present as localized blur — never a freeze. Static + capture-by-parameter for the same + /// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline). + private static func makePyroWavePump( + connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore, + ring: ReadyRing, renderSignal: DispatchSemaphore, + device: MTLDevice, queue: MTLCommandQueue, + decodeMeter: LatencyMeter?, + onFrame: (@Sendable (AccessUnit) -> Void)?, + onSessionEnd: (@Sendable () -> Void)?, + onDecodedSize: (@Sendable (Int, Int) -> Void)? + ) -> Thread { + // The chunk-aligned parse window = the session's negotiated shard payload (Welcome); + // the 64-byte floor mirrors the Rust client's guard against a nonsense value. + let windowSize = max(64, Int(connection.shardPayload)) + return Thread { + defer { pumpStopped.signal() } + // Compiles the two compute kernels on the session's first frames' thread — ~tens of + // ms, once per session. Failure = this device can't run the negotiated codec (the + // advertisement probe should have prevented this); end the session cleanly. + guard let decoder = MetalWaveletDecoder(device: device, queue: queue) else { + if !token.isStopped { onSessionEnd?() } + return + } + // Newest decoded frame index — a late partial (the reassembler's 30 ms fuse can + // deliver one behind a newer complete frame) must not travel back in time. + var newestIndex: UInt32? + var lastDims: (w: Int, h: Int)? + var alive = true + while alive, !token.isStopped { + alive = autoreleasepool { () -> Bool in + do { + guard let au = try connection.nextAU(timeoutMs: 100) else { return true } + onFrame?(au) + if let newest = newestIndex, + Int32(bitPattern: au.frameIndex &- newest) <= 0 { + return true // stale (or duplicate) frame — skip + } + guard !token.isStopped else { return true } + let chunkAligned = + au.flags & PunktfunkConnection.userFlagChunkAligned != 0 + let ptsNs = au.ptsNs + let receivedNs = au.receivedNs + let flags = au.flags + let submitted = decoder.decode( + au: au.data, chunkAligned: chunkAligned, windowSize: windowSize + ) { planes in + // Metal completed-handler thread — stamp + enqueue, don't block + // (the exact contract of the VT output callback). + guard let planes else { return } + var ts = timespec() + clock_gettime(CLOCK_REALTIME, &ts) + let decodedNs = + Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec) + decodeMeter?.record( + ptsNs: UInt64(receivedNs), atNs: decodedNs, offsetNs: 0) + ring.submit( + ReadyFrame( + ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs, + image: .planar(planes), flags: flags)) + renderSignal.signal() + } + if submitted { + newestIndex = au.frameIndex + // Decoded-size changes come from the SOF dims (this is also how a + // mid-stream Reconfigure lands here) — report like the VT pump. + if let size = decoder.decodedSize, + lastDims?.w != size.width || lastDims?.h != size.height { + lastDims = (size.width, size.height) + onDecodedSize?(size.width, size.height) + } + } + // A dropped AU (malformed / SOF lost / too few blocks) is just skipped: + // every PyroWave frame is independently decodable, the next one heals. + return true + } catch { + if !token.isStopped { onSessionEnd?() } + return false // session closed + } + } + } + } + } + /// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME` /// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target /// present time (when the frame is actually on glass), not the moment we drew. diff --git a/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift b/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift index 6115f9bf..05e45a36 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift @@ -12,7 +12,23 @@ import CoreVideo import Foundation import VideoToolbox -/// One decoded frame waiting to be presented. Owns a retained `CVPixelBuffer` until shown. +/// A decoded frame's pixels — which present path they take. VideoToolbox codecs deliver a +/// biplanar `CVPixelBuffer` (NV12/P010/444v/x444); the PyroWave Metal decoder delivers three +/// separate R8 plane textures straight off its compute pass (there is no CVPixelBuffer — the +/// planes never leave the GPU). +public enum ReadyImage: @unchecked Sendable { + /// 8-bit NV12 / 4:4:4 biplanar (SDR) or 10-bit P010 / x444 (HDR), Metal-compatible. + /// `isHDR` = the stream is BT.2020 PQ and the presenter must configure EDR output. + case video(CVPixelBuffer, isHDR: Bool) + #if canImport(Metal) + /// PyroWave planar output (Y full-res + Cb/Cr half-res, 8-bit SDR) with its precomputed + /// CSC rows — presented by `MetalVideoPresenter.renderPlanar`. + case planar(WaveletPlanes) + #endif +} + +/// One decoded frame waiting to be presented. Owns its image (a retained `CVPixelBuffer`, or +/// the PyroWave ring textures) until shown. public struct ReadyFrame: @unchecked Sendable { /// Host capture clock (the AU's pts), in nanoseconds. public let ptsNs: UInt64 @@ -22,15 +38,26 @@ public struct ReadyFrame: @unchecked Sendable { public let receivedNs: Int64 /// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds. public let decodedNs: Int64 - /// The decoded image — 8-bit NV12 biplanar (SDR) or 10-bit P010 biplanar (HDR), Metal-compatible. - public let pixelBuffer: CVPixelBuffer - /// True when the stream is HDR (BT.2020 PQ): the buffer is 10-bit P010 and the presenter must - /// configure EDR + BT.2020 PQ output. Derived from the decoded buffer's pixel format. - public let isHDR: Bool + /// The decoded image and which present path it takes. + public let image: ReadyImage /// The AU's wire `user_flags` (`AccessUnit.flags`), threaded through the decode via the frame /// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery /// mark) at present time — the async decode callback has no other access to it. 0 when unknown. public let flags: UInt32 + + /// The VideoToolbox path's buffer; nil for a PyroWave planar frame. (Kept as the accessor + /// the decode round-trip tests assert against.) + public var pixelBuffer: CVPixelBuffer? { + if case .video(let buffer, _) = image { return buffer } + return nil + } + + /// Whether this frame presents on the HDR path. PyroWave planar frames are 8-bit SDR by + /// contract. + public var isHDR: Bool { + if case .video(_, let hdr) = image { return hdr } + return false + } } /// Per-frame context threaded through the VideoToolbox frame refcon: the AU's receipt instant (for @@ -286,6 +313,6 @@ public final class VideoDecoder: @unchecked Sendable { onDecoded( ReadyFrame( ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs, - pixelBuffer: imageBuffer, isHDR: isHDR, flags: flags)) + image: .video(imageBuffer, isHDR: isHDR), flags: flags)) } } diff --git a/clients/apple/Tests/PunktfunkKitTests/AV1Tests.swift b/clients/apple/Tests/PunktfunkKitTests/AV1Tests.swift index 2217603d..2bd574d5 100644 --- a/clients/apple/Tests/PunktfunkKitTests/AV1Tests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/AV1Tests.swift @@ -237,10 +237,11 @@ final class AV1Tests: XCTestCase { let ready = try XCTUnwrap(frame) XCTAssertEqual(ready.ptsNs, 42_000_000) XCTAssertFalse(ready.isHDR) - XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 320) - XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 180) + let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame") + XCTAssertEqual(CVPixelBufferGetWidth(buffer), 320) + XCTAssertEqual(CVPixelBufferGetHeight(buffer), 180) XCTAssertEqual( - CVPixelBufferGetPixelFormatType(ready.pixelBuffer), + CVPixelBufferGetPixelFormatType(buffer), kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, "SDR AV1 must decode to NV12") decoder.reset() } diff --git a/clients/apple/Tests/PunktfunkKitTests/PyroWaveDecoderTests.swift b/clients/apple/Tests/PunktfunkKitTests/PyroWaveDecoderTests.swift new file mode 100644 index 00000000..8c28cd15 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/PyroWaveDecoderTests.swift @@ -0,0 +1,292 @@ +// 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.. 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 diff --git a/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/au-chunked.bin b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/au-chunked.bin new file mode 100644 index 00000000..ccb33fe5 Binary files /dev/null and b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/au-chunked.bin differ diff --git a/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/au-dense.bin b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/au-dense.bin new file mode 100644 index 00000000..be3a9c74 Binary files /dev/null and b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/au-dense.bin differ diff --git a/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-chunked-cb.bin b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-chunked-cb.bin new file mode 100644 index 00000000..00f0e441 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-chunked-cb.bin @@ -0,0 +1 @@ +ssxvvxy{˼vvrtxuzx}ȼrrstuxtz~ʼrsuqtuww|~~~Żqorsqsutz{~źoprqrqrq{{}~|}|ønllpoprpxzxz{|}|ójmnmmnonyz{{z{~±Ͼÿ}}~{|}qtrtuuuvy~}Ʒz{{{|znqrqtpqqzy~}ǵwzzy{z||lpoopqtq|}{z|~ĵпyvzyvz|zkllpopsqyvx||}z}þĶϿtuvyyzyzjlnlomoo}~vwy|yxzöͼrvrxyxvxkliklnmsyxtxxz{{¾˽psrswsx{ijjhllmm|uuvuvv{y˻qosqswutehijgikl|~~sttwuyvwɹ`aaaccdguvwuxwy~kklnprpq~vyyx|zz}ó^]c_b_acvwvxvvyxhjmolmpqxxzwxx{{]]\_`^`ctxuvwuwxhlkkmmkl~~uxtzxyywñ\[]]^ab_qsuttxutihiikjjnssuuvvyy~Y[\\[^^]nrtsstuwcigfkjmm}~~sqqtwtrt~[Z[Z^[`\oqsrspsuedfefhhi{|{{~~pstputsv}~z~WW[VXY]]qqnrppqwddgghgjkzzz}~~}nqprrstu{{~~VYWYXZ[\nklqpqppcaeeceggyxzzzzy|mooooopry{{}~efhghlkh[Z[\[__]rrqtttuuiifihmjj{~}~}rvuvwwwweeegehjjY[X\__\bnnrqtrtsbhghgjik|}|}rsvvrtuu|}~aedcddhfVX\Z[\\\ospqrtttcdeegfki{||{}}ooqpsrsw||~~_bbbffehVWXXZ^Z]nmlmnopqcabgfghky{}z~}}}oorsqrsu~{|{~~^b_`accgVVVZV][Zjpmooqmq``dccfggzxwzzzx~lpponrqty|}zz~~_a`ba`bdUSTXV[UZhlomnlmna`b`bedgvuzzwz~{lkqknpprwwwx}{|~\\^_accbRSSVUVV[igjkkmnp^_a`b`fcvxy{xy{{ikmnmppqwywyz{}}[[]\^a_`RTSRSUWVdhimnkkj``a__``drtvuvx{{lknmmkonuyyyxzxyHKKKMLNO^_dcaeffWXWWWYY[kkllompqaecfdefgwv}x{|}}mmrrsprqw~x|}}HKLLLKOOb_`aecbbSUVVY]ZZknjlnnpr_b`cddcdwvwy}{~~mlppppptww||x}|}FIHLILJN_^`_^bccSRUTWVW[jiinnnmp```dcacbuvvxzyz|mjlkoqpsvzw||z}EHIJGKIL]]^``_`aNQSUXSXWhjkjmmnn___`adbcxuvxuw{zjmllooloyvxvwx{{GFIGIELH[Z]]^_^`TPRRRUTXehhjjmkl]\a]_`bbsrsvxvwxhjklnnmotvyuwx{yDBGDEEHI[\Z^[[\\OLQRRUQVdghiikkk[^^]]]`_sqssxwwxfhjklhnl}tuswwwzzFAECFHIFU[Z[^Z^`OPOQPTRVgffiigijXZ[_Z\\cqtsqttvvehifhimm}|~rsuttuwx?@BDDEDGV[YZX]]ZLNNPOQQSdedfhejfX[[\\Z^_oonspussdgfhilgjy}~~nqrsvrvw}~QOQPUSSWEHFFIFJLZZ]a`]_`RTTRWUWXgkijlkoi___b__aasuvwvszyhklmmmnq}vuvw{w||PORPRRVUBDDFIHGKZZ\\]Z`bRQPTQWUVgffijillX]]^`^carruwuyzugklklmkl~vsywuwzyMMNQQSQUBHEGEHFHZZ[[_[_\OQQTSRTPddfhhfhkY_^]`^`atssuuvuxfhhlikkk}}swsvvvwyLJJPNOOS@BEBCEFEVYZ[[^Z[KNQPOPTSffeegifgZZ\Z^]_^mtqusqrtfehfgjlh||{}}rvvuvwvt~~}MIMLLNMQB>@BFDFDYVVZZZX`KKNPORPOccdefejgX\^\[]\\nnnqqssxbdgffghg{z}|}{pputuuvvLKINHMNO??>>?DDEXUUXXW[[KLLMNPORcabeegfeVWWXZZ]^mlpopotudbggeffhxy|y~{npnssssrz|{}~~~GJJKNMNM==>A??BBRTWXXXXZIJJLKNPP_bdacccdUVVVZWZYlmoqppstbcceecdgvxyw|z{~nnoppsqr{xz}~~|EGIJJJLN;:A@?@?DQUVSWUWXIJIIJMOO^ab`adcdVTUXXZXXmljlmproaabbcdcdvyyx{y|zplmnklprwxyz|~|65577;99NLPNMPPP@A@EFFFHVVXYW[[^LOMOOQPTbeeegegiZZYY[_^_lnprrqsufegehlkiz|}|~rtsstuus|}65668878KILKNMNQB?AAECAHZUYYX\W[KLMOQQPO`cedefdeTW[ZZ[][pnmtqotrdgbhfiifzyz}~~mqrrsrru|}~~34337449IGMJLKNN>?>B@ACETTYUVXVZJKNLKNOO`abcccfeXYWWY[Z\klnoqnrtb`cbffckxyy{|~|qpprntss}z}}}|14145545FHHHJJLJ=?=@@A@@SSUUWWVWEKJJNNMO_a_acaedUUWVVYZ\knmloqop`_eedcghzy}w|zyllonqsprzx{{{~|.04.4423EFHHIHHL<<<>>B>?QRSUTTUWGKJILJMJ]___`_cdUUXTUXXXkimlmonq_^a_dccdwuuyuyzzkompporoyyy}|x}-.112023FDHDFHJL;9;>=?@AQOSRUVWTFFHKGHIK\_]__^c`STQURTVVeikjlokl[`^^adadutvvyxxymklmmnns}xwz{y|y|~/--010/1GCCDEGFH98==;<==PQPQRUQUDEEEHGJK[]^]\_`_QQPQTTTVgifhljim]_^]^ac^utrvuxwxkjiimmmm|wvtvzyvz,--+1021CB@DCGIG5779:<>?NNQQQPQSCEHFEJEHZ\Y^]]_`LQNSOTSUdfghjjjm\\_]]]castuvtstxfffklikpz}uswvuyvy~:=?:===?02015755IFIJFJKN;;>@>?@DSPTTUUXZIJJHHNOM^^aab`dcTWVWXWVYlkjnonpnb_`dfefewxvx{x|zlkopprrqzyw{|~}7;7:=;??1/143345EEFHJJHI:<>=?A@@SQTSSRWVFIFGIGML]^[a`b^cSVUVTYYUikkmlnno^abe`ed`vvvwywzzlkkknmnpvx}z{|y89=9=<<>0..01521FCEBFJIJ:9<=<>>>MPOTQTRSHIGFJIJK]^```\`aNQTTTTWWfgjfkjoo^`]aaadbtwvuwywyhlmklmpptyyzzx{~686;::<;--,/0023AABEEGIJ;8;:<9?>ONNORTUWEFDJJIHJZ\[]^_`bPRSRSVVVgiiijgml]^``_ba_ttvuvxyxgglnjnmo~vxuwyvzy747788<LLJNKOOPEECGGGGJYXZWX\[[NNRUTTTWdeebggeh[\\]a``bnpqopqusilhkmnpo|z{~|~}uuwv|{{y02265356**,0-022>=@C>ABB78:;=;=?GIKMLOONCDGHIHKIVRUXZYV[PPSQUSTUedcbeeef_babc`dbmopppsptjnllllkmwz{|{|}vwwuvuzy''(')*+)5769:::=23336657@ADCDDHHA>CC@BMMOQPRMSGJMMONPOV\Y]Z\]]UYXUX]X\fdffgiijcaeegehgqqrtsruwpqqqotss|}{{|}||~&(&(+*,+346897882256554:@>B@@BDG=?<@DACDMLLPPRPOMIMLKKPPTV]XZ[][XWXV\W[\becgeijkddddeeejmosrssttnsoposvqzz}|~z}{}}~)(&*()*+332575660/563575;=?BA@CB:=?=BAACJJJNMMOPJKMIOOMLSVWX]XY^VXYZ[[Y^baeceegedcbddgffopmqqrprpnrrqrstz}}{||{}{|}~}%&((((()12233556/4454787=@>=>BAA?BGIKMMMJOIKLKLLNLTRUXVXXXUVYXYX[[`ac`aaddbbgeggeioolpoqtsonosssoryz|z~~}{zz{{~~(('')(++-3012247024317:6;8>?@CCDCEFIJINJLMLMLMQLORUWWUXXZYWXYWWZZ]`_`debd_`dfdfgjlklomqnokpqpqottvwyy{{{~x~}||~}&))()((,*1/220231136665:99><<=A?A@BBCHEHHHIJKKLJNMOKORSTRUSYWTVZWX[\Z\\\`cb`bcbbffffigjmkmormlmposrqswwxzyyz}zy{~}~}}")&'*++,0-,./243//446646897>===??=A@BCCFEEIGGIHHHKMLLMLPPQQRUTUUVWVZ[Z\Z]Z\`__acbb`ccfhghilmkjplopqpopqttvvuzx{yzx}}~~~ \ No newline at end of file diff --git a/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-chunked-cr.bin b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-chunked-cr.bin new file mode 100644 index 00000000..51be3940 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-chunked-cr.bin @@ -0,0 +1 @@ +qqvssuvw{{|}~|{uuprvrwuzywy{|qqqrsvrxy{{{z|}|}rsupstvuwy|y{x}{z~qorsprtrvw{y}||y}~pqsqrqqpwwyzwx{v|~¼ommpoorouwtuwxxw|||koonmnonwwxxvwz{z}qtqsutsttyxyzy}y~~~~nsrqtooovtz}zzwz}~~}}~~mqpppqtpyywvxz{z|}||~z~lmlqoptpvrtyxyux||}~{|}~~lnomompo~ttvyvsv|}|~|}x~x~{}mnjlmontxvqvuwww}y~||}}x{yy~y~lmljmnnntstrsrxv|}|y}{y~yv{xz~{zhkllhkmnrssuswtt|y{||z}}egffighk||}{}|~mmmoqrpqsvvtxuvx}|}dbjehdeg}|~|{~~jlnqmnqrvuxtutxwzy~||{dcceeceh||}~{}~jommonlltvrxvvvs}z||}{~cbccdghdy{}{z{ymjkkmlkorqssstvvxy|~{}}|acdcadecw{|{{{{fljhnloorppruqoqy|y|{~z|dbaafbgbxy|z{wz|igihhjjk~pssntrqsxyt{yxwz~_`c]``dd{{w{xxxiikkkjmn~npprqrrswv|y|~x}_b`aabcdxuuzyyxxhfjjghji~}~~}nooonnoquwvy{y|y~|qstsswvrcaabaedb{yx{{{|{lmikiolkquttuuuty{{~}{}~~rrrtqsuvac_cffbiwvzy}y|zflkkilkmqsuuprrrvxxzzy|znsqoppuq^`dbbdcby}yyz||{ghihjinlooporpquxzwxx{z|~looossqu_`a`bfbdxvuvvwxyhdfkijkn~~ooqsppqt{vwuyx{z~lpmmoppt``^c^fcbt{wxyzuyeehhgjkk}||lqpnnrpsuxyuuz{x}npopomoq_]]b^d]brwywwtvwgegegjgk}{|mkrknopqtsrtyvwx|~}~kkmnpqqp\]\`___dtruuuwxzdegdgekh~}~jmnonqqqtvtvvwyx|}||}{kkmjlpmn]_]\]_`_ostyyuusgfgedfehy{}{|~omponlposwwvuwtuz{y}||}~UYXWZXYZlmrqnrsraa`__a`cututxuxyfjhkhiij|{|mmrrroposzswxw{|}~UYYZYW[\qnnnsqoo\^__bfcbvxsuwwyzdhdhhggg}{}~nmqqppotsrxxsyww~~SWUZVYW[omonkppq][^]`^`dussxxwvyffeihege{||}~oklkoqpsswsyxu{x|~{~TWXYUYVYmlmonmnoX[]_b\a`suuuwwwwfeddfifg|}{}lpnmpplowsusstww{|}z}~VUXUXS[Vljllnnmo`[]\\^]aptsuuxuvdbgcdfhgzyz}|}~klmnoonprtwrtuxuz~z}~TRWSSSVWlmjnkjkkZW\]\_[_pssutvvubfeccaee{x{z~~ijmmnjplssqvttww||{~}{}~WQVSVWXTflklnjnp[\[\[^]`trsuurtu_abf_bbiz||x|{}}iklijkpnqrtrrruvz{yy~{}zOQRTSUSVgmjkinmjY[Z[Z\\^qqqrtquqadbdcadexxv{x}zzhjjlloil}mqqruottyy{{z~zyececifeiTWTTWSXZjimpokmn]_^\a^abswtuwuysfeehddeez|}}|yjmnnnnnqtsrsxsxyx|{{||dcfcfejhQSRTXVUYjjlklioq^\[_Za__srqtutvw^dcdfchfzy|~{zinonnnlluqwurswu{~~z|~|bbcfegeiRXUWTVTVkjlkokok[\]_^]^Yqprttqsv`gedgdfg}{{}|}{~ikkokllmrvrttstv{yz||~ya_^ebccgPRURSUUSgkkkknijWZ]\ZZ_]ssqqstqrbbdaddedv|y}{xy{ihliiloi~quustusqyywy{zy|~c^ba`caeSOPRVTUSkhgkkjhpXXZ\[^[Yppqqsqvsaefcbdcbwwwzy{{ehkiijki~pousttttz{{{y|~y~cb^d]ccdPQONOTTUkggiigklXXYYZ\Z^qopsrtsq_`_`baeewuyxxw}}ifkkhiik}~~npmsrrrpvxvzxyxw}}}^a`bdcdbOOORPOSRegjkjjikWWWYXZ]\mqsnqpoq^`__b_b`vwx{zx|}ghgjifgj{}~|~oooporpqxsvyyyw{z|~}~\^aa``bdNLSRPQOUdiiejghiWYWVWZ\\mppmoqqq`]^aac``xvsvvz{xgfggghgh|}~~rmmnkloqttuuxzwz||}~~}|KJJLJOMLdaecadedQQOUVUVWggijglknX[YZ[\[_osrrsqrtbb``bfeeuvyzyxz|jijgkonk~rtrqssrqvwz{}zyz}LKKJLMJLb_bacbcfTORQUSQXlfkjimgkWYY[]][Zoprprrpp\`cbbcdbzvu}yv|yhkelimlh}~mqrqrpptxxzz{xyy~IJHHMHHM`]d`baccPQOTPQSUgfkggigkXX[YWZZZnooppprqab__acbctvwxzvz|fdgfjifn~~}rpprlsrryuyxxvy{~}GKGJKKIJ]___a`b`PQNRRRPOfeggihghRXWW[ZY[momnqnrp_^a^^acdvxvuxzxxedjihgjk|~}mlpnqsoqwtwvwyzv~}}}}DGLCKJGH]^__`^]bOONPOSOPeefggfghUZXWYWYVlnnmnmqq__b\]```uswuwywzdcgciggh}{{z~~~lqnqpornwvvyxsyz}~~}}~CEHHIFHH^\`[]_abNLNPOPQSebgeiijfUUVZUVVXlnlnnkqm^^[_[]_^ptvuwytuafccfiei|{||}}}olmomootvtwxuxuww}}z~|HDDHHFEG`\[\]_\_MLQPNOOOeecdehdhSTSSWTXXkmmljmom\\ZZ^]]_rupswurvcfdccfhb}{y}{|}nmkjnnnmutqswurwzy|{~~}DFEBIGIG][X\Z_a_IKJLLOQRcceeecdfRUXUTYSVkminmlnoW]Y^Y^\^prstuutwcbecccjf{||~zyz~hhgmmjlr|~tqussvsux}}yy}||VXZUXWVYGIFGKMKJa]`a]``cMMPROPQTfbffggikXWWUU[[Ymlpoomrp^a``a`^awusxywywgdeikjjh}~|}}~mkoqprrpwurwxzxz{~~~~SWSVXVZZIFHKJIJJ]\]_aa^_MNPOQRRQhegeedihTWSTWTZXlmjpnpkp^`^`]ca]tuuxvxwxcggjejhd}|||~|nllknmnosuzwx{xt|z~{UVYTYWWYHEDGHLHG^[]Y^b``NLOONPPP`dbhcgdeWXVTXVWXmmoonjnoX[^^^]a_rrupusyxefbffeif{~}|}|~inollmqpqwwwwtw{|y~{z{}SUSXVVXVFECGHGIJZYZ]]_`bOLOMNKQPdabbfghjUUSZYWVXjlkmnnnq\]]]]``_suuruqxvddggdhfd{{}|}~~jinplonpuvstvswvz{~}}|{UQTSTUXYHGIIHJIIXZZ\\Z[\POPOMOPObcfcbfecSWWWXXZVijhlkmkqZ__b`b`bqoqtstwwbdbehggfyy{wzz{|jmnmoppm|usxuztwt{~||{~|}SRTPTWRVEHGIJKIHYZZXZ]]]OONNTQSPbbeaebdfUXUUYVZ\kgljjilm]]a_a_^aoorsroqufcgghflivu{z|v{|llnlnmnqsvuyyzwu|{}}~~SRSUTTRVFEIHKJKLX[Z\W[]^QPNOOPORbb_c`ccdVVTXWXWZkikghljk[[_b``_cqrrntspscddeiggiwyywxy|znqloqqtrvuwu|{zw|~}~}OQQTSQRTEDFJGJKLYWZ]W[[ZNNPQSPRT]`abaedcUVYY[Y\Zhdgikjfk^^a^b_`asrqorrqqiljklhmjvxyxy{w|ptqpqpnq|wxxuvtzx}}~}~DFEDEFGERTRUWVVYKLLLONLOXZ\[\[`_QVUXUWRWecfdffgfY[\^^```iilnjlkmaddeeekepvrsvsttijlllomn{zx{{z|tvtssyvyxx{{{z~{EFIGHHIHUSPSRSSVNINPOLQPY]X[_\[]PSWSXXUVbadedg`gX[^_`^`_gmimjlllbfeadidhsprqstsuljnnolonzyz|zy|~uvvurwvu{{|}~z{}EGEFJHJIQRTVWTUUMMOQNNMT[W\XX[]_SUQVZVXYcabeehdc`Z_^\\a`egnijkljgeecjcghorotqtvwnnnmnmmrvw|z{{{{sxtuswzt~{~{|}}~IGFJFGHIRRPTVSSSLJQQMORNUWY\ZY\[QTVSXWWY`__cbbcd]^_[aa_^eghinhineggiiifkpnrpqqspomlnmpnnxzuzyzwzvtxwvwxy||}~~|FFIHGGFGQRQQRSSSKPOPORSQX\XV[][]VTUZWWTX^_accc^c]^_^_^`]fcfjgjhideiggfihnnqmmmppmmroqpnryxuywz}{vtuyyytw~~|{|{}KIGHIHJKMSOPQPRVMOQOMSVQWSYZVYZ]VUVX[Z[Z[]``_d`ab`a_ae_behiifijlighifehhkolmqrnpjkpqoqrtvuvyuzwvrxxvwuzz{}~~z}}~HKKIJIHLJQORQOPRNNPTSRQWUTZWWVVZWZWZXZZZ`]_^__```a_bbc^bfggdgdkhefjggjkikjjnqploonmrqqqtqtxtwx|uttxvzywy~}~~~~}|}~~DKHHLMMMQNLNNRTRMMRQSSPSTVSZXXXYYV[Z\\\^]]a^]`^^]`baab`edddehghghhgklkmilhjommoqoolpnqsrrtvxuszuxxzxwxx{{}|z}}}{ \ No newline at end of file diff --git a/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-chunked-y.bin b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-chunked-y.bin new file mode 100644 index 00000000..a8305210 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-chunked-y.bin @@ -0,0 +1 @@ +//0.35/400512245:36945;57;;<;69:"9=;<;@C; !%!!'"%& %#!#'$FCFADC@EBBDFDEHF(%***&((&%&&*-*)KHKKHFJIHGHJJJLL+-.//001-,100.1-QKPRQLRRMNNSMPMQ6406008524125234RQUUTRSVYWSVXSRR::9:<=;9;<<8;?89[\\XV[^^^ZYZ[][[>@?=@@>B<@B>B>CA5/02612344704882:794;<<9;:<8:767 !! !?@?B;=<@<;?@?B=CCC=@BC@>?A6457218174333993<79<96>77>;=? " " !#$$$?=?AB=BB>C?DAC@D$#"$$#%%(%$$')$(FBHBGFGDJDFHDGID&+,'-*(+,--/-).0LNLLHLHNJOLKOMKJ/,123-232/./.441SMORTTPPQUORSRUT68277794:556:845YVVSSXYTXVWZW[XY>::;<9:<>?::@=@>\]\^Z\Z\]_`][\]_@?A@@@CCAB?B?D@D7575533586397:86>77:9899:;8<>@>> #! #%$!"&%?A>AAD@E@D@@@CDA)"$'&&%'(**)$(%'GDCCJHEDFDGFJKHF*.(+++-*/.,+/-,0MLJKIIIPNPMQQNKK0././52353313426PNUUQTTRPTVOUTUW7:::646;95759<:8UXUTXUWV\ZXXVYYX>9@=@A<;>=>?A;B<\Y[^Y_Z^[]a\]b__D@?CBA@BFAF@C@BB74954857;69;668<  !><=<<<9>:=?<@A=A!#!! % &!$&$>>?C?D?@ABDCF@@H$%'(('$$*'',''(*FFHIHKJJIHEIGMMJ///-+*+++22/+//0LPJKQMOMLNNKQOQL6.51464775344515TRUOSRSUTPXWSURX867;8<68:7:98=;8V\WWXV[]VY]]ZWW];;@>@?;@@@B<=>@@?<@@?@;>>!&#$#"&#"'!$%'#ABB?BCBDGAFFCFFH*'+)''%+&'',&*')ILGEHJHELLKKIHKM+*-/1++0,-/+/02/ONMNPLKMRKRQNQPT62/1251753237525SUPWPRUWRSVXSYVW86<<:6<:67;8<8;=[\\\XWYY\WX[^_[^BB;>B?@A>>C@@ba\_c\b_]^bc_bbcE@DFABDFFFFEHEFE6;9:<=89<9=<<<;;!!! " $=>>=@AA=AC@=?B=>!' %!('#%&"(#%%@ACFAEECAFICFCIC+(&)-.*&))+,*..-HKMHILKHMGMLOKMJ/+-0..22,-01110.NRPLMLQQQORUMRRU2773677568:456:9VUTVXYVSYTTVVUVW78;<<<;;<<;>=:=9[]Z]__[\]Z^[\`[_<>=?=@A@AC?@@>@?_^bab^_aeb`_de`cGBBGDDIFFGDEGIGF:;<9:=7;7>7<:?== !!!!# #$"@<>C@C>A?@@>CCCC&&%%"%##"*%(##))AAEHBFJDFFGJDJKH-*(*.-(-+).,0)..IHMKIKMJJKKLPPNI/022/41005.01335NQTTNQSQUSSVVPQO68858879::6997:8RUYUYSZXXUTWZTZV99>>=<=;9@=A@@:<]^ZY\`\]`^`\a^^`>B@?>EEC@CEB@A@@d^cdd``ebfdaffb`EEEJFEFFIGGGFKIJ>=:==^`^`]_^][_a_\^``FBFAEC@@ADECCAHCaf`f`ggfggbdfaadFFHFFIIHGJKKJGJK:<;;:=;?;:@?A;;B! $ $# &&%!&@A@@ADF@DEEDDEA@)(*)%&$++*&')(&)JIEGDJHEFFLLFIMM-,---.0/10-,,1.1JKOOOONMQLQNKNSO.501012553340116URPRTRVQPSSSVWSW9;;;69;<7=:8<7<;XXYYV[UXW]Z^ZZZ^>>=;B:A@=@B>A@ABC@>a_\^cc_cba`^bab_EHGBDADDBGHIGGGIchbeifcfcihdiediGKHJJKJJNLJLKIJIB=A?B?@C@B@=>BA?&!"$"#!(&#(("'(&GFAGFHEDBDBFHEDC'(,,+++.,())*/*)KGFJGKJNNOLKMIJL/,/013300440./21QSLOQQQMOSSTONPQ755735458:55:798RVXSYUWYYXXTY[ZZ=7798<>>:@<<=9>:[\]Z]_]][X_ZZ_^]BC>@>=ECBBD@ADD?adcd`adccedcd_g`HHGJCDCEGJHGEGKFfddjegefedgjehekMIIKLLMLOMJKKPNMC@A==>?D>C@@D@BA!##"$)"$$%&*%+%(GCHICCDICDCHFKFI.,()(()//()*,0+/HIILMNLJOOMPPPLN/1.2.01111051204TNNNNSQSQUQRVVVQ853946:39:;4<9:8SWXUWU[WTX\YZV[X=;?<>;C>ACFFBEFCFAAc^babfcd_``ee`ecDFDEHIKIIHEGIFJFjjejeghlklikgffgPOLONPJPMMLMLMQP>C@A@CCAABDCGABB%&*#(''$$)%*+&,,HEFEDIFFKILHHGGG**.)./+-1.1++.2+MNKLMLMKQLQRMLLO/33.635106244578QROVQVPSWUTRXTQV5448959:5667:87=VWW\XUZ\[]VV\XYZ<@>;@=AA>>?@>@C@]\aZ`b\a_b]^\__^BDBADADBADHFFGHEdaad`aagcfgcdifhGGJKLMFGHLJJIHGKfgglmjmjlihhlmjkOQLOPPLQLNLNLPOR?B@D@B?A@C@FDCDB(&)'*)(),)')*,(+IIEKGHEKIHFGMKIJ000,,,,10,+-/+0-JJOJLPPQRRPPLRMN4606661028456684TRRQPVVVUUTUXTSW<4:98988::7698:<=C=@D@?Bc\a]]_^a_^__edacHCGIHGECDDJIKGGJadcgdgfjfhiffddiMLNLNLOOLIIIINMLhmhilmjhmniipmjpMMQSPNORQONSSPOQ)**)(+%)($,,((**IIJIGIGLLLHKHGLH.+.---*.,,..2-.-MKMOJLLMPLNLOQLQ/42/646413211143TPPQSPSTQRSSSWVV66<5:<::<8797:<8XVWX[\U]X[W]YW\W=A:??A<@B?>A>CB>_]]\`_^_c]`ba__dCFGGCDHDBGCBIGDCdecbbfhfghdcgdccEHEKJMGGMGLHIIHIjfhmifhgljhlkihoNKOPROROMRRRORONpmqoqqrprnrqnqrs*)&+++*'**&-)().HGKKHKILKINIMNLJ./2/11203-.--122JKNQQQSOPRMSQTQO2235412713693968URPWWSXTUUVYTSXX786;798978;9?=>=]Z]YW^ZXY[\_XXZ^;=C==CC>CCCE]]c]]]`b]bc_adcbCCDCHGBDCDFCCHDFhdgceejgecdkjdejILIMLILLLIINJNPKkhnnmljommjonlnkTPNQQNTSPPQNQPUSsnsronppssrqsqor,&-,-&(*+,*).*+,FLMKJKLKKMIHMJKH./1,1/1.13144020LLMPRORQOQQNQQOT5883263826887494UVTSVXXUVWTSVUWU89;>9=8<<<@<8<@9][[^^^Z]\^Y`Z]`ZB=AC==DA?ABE?AEAccdc^d__`edd`ac_DIFCFFHICJGDEFKEgbbhffjejgiefhgeMHIOJOJMKMMJOPNQljhmoojlklpnmpnnQPSNQUNOTPPOPSSTrqnrrpnturspqtur'-*(.++,(/*)0,,.LGMILNJJMNMIIIKL/0/0.42..345//00QSNNTRUUUQTTOORR24522:334:4758::URWVVVTXTTWTY\W\:>8?:=>==@@?;>?=X][[YYY\Z_[a`[a]A@>C@BBFD@@@CADEadc`c`ebcbgdfcbgDFHIJFGGIIHHEGEKieifkhkhejfkkfjkPPILMMOLKQLOLNOMjljjolmplnllprrpNSPRSSRVSVUVTRRQrtsstpqqrurvutvu*-0,,,.-01*,,+.+HJMMMPLJMPLLNPNN/5.145030/003362PUSSVOORPVPWQWTQ43:498575<;5<8:9Z[YWWXYV\YZ[Z[XX::?==?;<=@>>=<>B]\_^\[a``\ba`]`^F?EECEDEDDFGFGHIceddbef`egffggihKGEELMGMKKKFKGMHlimjkfghkgmmhkihJPLRQOQOPOOLSSMNnomorpnronrpmoosQTPSSTRWQXXSRXVSssvrtqwuvsvuvsst)/0+./+-+-2,21,2QKOPLNOQKNPQRRPM4400365466757767VVVVVSQXVSXUVXXX<9;7==<<:78=9;<:[\VWV[XVVZ^XY_^X?;@B<=AA=B<====?b^ba_`\_^abc`a__G@HAEEEGDGFDDGEDaahgaiddcgdbdicdFGMHJNLKHMKOKJMKgkhmlljolkjljiojONLSNSOPRNRRTTSUlqlspomrsprrssuoXXUSXUYSVYVWXTXYssswuwyxxvuvyutv,1.,10+,,..//4./LMMKMKRRMNLPSTQS4775033153458947SSRQQQVXVUVXSUVZ799<<<9:;7<<<:;=WZWWZ[ZZW\_\Z[[]?>;8;?:<>:=@;[YY_[]\Y^[___^Z_ACB@?BE@BAB?EEAFdb`]]__b_cda`efaBJDIHHGHHJGKHIJLigcejcgkgjfdfgkeJNMIIOLJJLKJNJNQkopljlkopqljpppoQRRUSVOSTSVQVRTVtsptqpssuuwuqqrqTYXXUUW[[ZV\YYYYyuuuyww{yxyyvvww12-12-4322412306TSRRURRROUNRTSQV24963684;:;8:79;TXWYXXVZUZVXYYZ\:?;;9>;?;=::;?@=__]`]]`]Z\a[\[]_CD?BBC@CFA@DDGDA_e_afac``bdgf`dcIDKGFFEHJJFGKLHJiihjikihjggljfgkJPJJQJLLNQPNOQMLjkqqnoojpmorrqqmRROPQWPWRSUTTQVRsurqttptqrxwwuuyUX\WYWZYZ[VXY\]Zuyx|yzy{|x{wy|}}/125431545375042ONUOQTRQTURVWQTS54:;44::759<7<=8T[UVXY\\ZWVVXX\^>=;<;:=>?B?@<=><>=:>9?9@=;??9:?>Z]ZZX]Y^_ZYY_]^\?@==E>@CEE?EFEEDb^ee_a`abdac``dcJEFCIJHGHJEJEKGFcihhggkffekeligfNILOPINQNKNQOLKQoijmmjpolmkkqkqnPQPTRSOSSRSWWVTXurqvsrotvrpsvvsqWUXUY\XX\[VWVZ\Xtyw{|zxxxv|zv{yx[]Y[^`a\^[\a^\_b~}~{{|~|}}353756477;88;5;;YZUWWWVT\WZYUYX\99@@>;A@?;[`[aa]\]Z_a^ab^`A?EDEADDFFECCEBEefbafebbfa`fbcbgGKIDJKGIIFGGJMKLhfffijlfljhmmihmOLKPKPRPOMMKRSNMkmkmqonlrrponmpoPOVSWPQVWXTWSUUUtwswwvtuvxvywrwyYVX[\X]W[Y\\Y[]Yv|x}{||{x~y}yyyy\`aa`^b__`^]]c`d~}}}}7477<996<6<=797;UV[YWY\YXVXZYWXZA>@=>>?A@==C>@@?`\a\b\]ab]cb_`^bBDFBECCBHHGDBGGEfghafccbfchdceceLFGHHKMNMLGOIKOOgfiilhnfmgkjikooOQLOPLSPSQMOMMROmmnqosnrpssqpopoXUXVSXUWXVXVTVUUusttwuvwsuyywzyzY\\Y[]YXZ___Y`\]}~{{~y~z|y{\^]bbda`adad``d`~9;668998:8;;=:7:\U[W][YZZW[Y^\_[?@;<>?AC>D=B\b]ba]`c__aa`a`aAEDFCHGGFIHCHGFCceaefehihfbejghkFJINGKNMHNIIMOLHmmlhggloliolmkolLMRMLOMOQQOTTNVTlnlpoqtqrtururuoTWWSSWVUTYYTYXUZvxvtxwuuzuxywxzv^[\]ZXX[]Z[_[][[y|{}|}{{|~{|`c_^be_`aee`cebe~~WUYZT[\UY\\YY[\Y<>;<;>==??C]_`]\___c]`bb`b`GABABHBCADIFIBHHaceebghcgfgbcdfcJHLFJJGMHMMHHOJIlnlmilinnoojimolSMOOLSSQQQRNNSNSornpnqoprtossqsuTWTSWUVTVTUZXWXYvruxyzyyxwvxytyv^WXX^\X^Z__^^`a[yyx|~x~x}~zyzzaa_d^_bdbdb_dcfc}~~hffgicfedeeijjjgWX[X^^X[__[Y[YZZBABA@@BCC@B>CBCA^``aabddd]adebafFGDGFGIEJCEDKGHFichcbjdfhedehjfiNKHJHJLOOOPNJJPNhilhjoikpolkpqkjSMRNPONPPURTURPQunspqrptuqsprttuWUWSZXUUYYUVXWYZyut{wzzzvuyyzy|z`_ZY^[\_\_`_```b}zzzy|~}||||`ef_dadfb`afgdacjeiffejhfhkiifjg\^[W]]\[Y\\Y[]]aA?@D@BB@@>@@C??C]`]b`cbcc_a_bdeeHCHCICEHIJHEJKEKihiijkfiieiffijgHMILOINKKLPKJPNOjpjipmnmlpjponkoNUUROTOQPWUQVPTUqsopvurrsutppqxsYVXYXWZY\XW[U[W\uvyzzzyzzyz{yxy{[[_a`aa^[\`\ba`]{~||e`c_gc`eeefcdbhbfjgihjjihhhikijg`\\Y[Z`Z__[^]]`\E>EFFD@FDEBDDDGDd^dbbaf_`fbdabbfEJGFFIGLLELJFKHJghkiifkmhjkimfgfOKKLOMOQQQOMRPNLknkoqlkmqmpmnqrmRSVVVQQUQUVWQTWQtuustpqtsvvuutryXVZWXXZ[]V]ZWY^Yv{zx|y{{zzww}~|{__b_^\bb_]\]\a]]}~{~~geageebdbidgdcgjjlklgkikkjjiklmkZ^_][`]`^\\_a]`cFCEGABEEFBBCEDEC`efgefaggggdgbbcIHEHGFIJKILLLKMJkgikfhmjmhjkjlmhKROLNRKRPSPNOSSUqqplmqqtmsnprortVTWQVQWWRRWUTYYTtrxxqwwxuwsvusuvZZX\XXWY[YZ]\]Y`|wv}y{z}}{y~|zca\]^_ad]decdcac~~didgeccjggfjdkhjmghhhihmlknjnlnk_aa\^]_^^aaddb]^BAAHHHEDDHIEDDCHgbfcghfdigjgeffjLJMJMMGKLJJJKPNLkiminmminmjomppkRNQLQMTOPUONPQORsnrpqpsqonurssotYTXTSYZTWTTUV[VWwuyxtvwyvuuuyx|u_^[X_Z\]]Z`ZZ[ZZ}zyx{zy}|]^`cd_aed^ef`defchfgggegddfkkfflnnokkokojmolmlnn_`b]bd_cd`d`eacfGFDIICHFEEJJGJEDihfffcffcjeegekfLKOMNLKNMNPNQNMPlljiijojilllkojrQTRTQQSOUTTVSTTVnonruttuttssvrqvXVVXUVVVXWVU\XXZts{tzz{vuuvyvyw|\`_]\ZZ^_`[[``[_{~~{}|~~c`abaaddcaghfhehjhhhghlmemmmgjjglolnmoklkoooqopo__]bdaaa^__`cdcbCFIIJFJIEKIJDGKHcfihhkeelkkfliimOIOOPNNLMNJOLQMNmiimljlmmqkkkkmkOOUTRRTSVURUVPUVqrtuusvqtpxtqwtrZXY[Y[\U\VWXV[YVxxxzzv|ww{yzzy{|_Y][\^[^b_a`\`_`z}}~|}cdaabaccggefddcihjehkiijjmgngiglknoknpqpmmomnmon`efcdbfaecfgdcadGFJDKHGHFJGHLKKMegijffekjigllimiMPOKKPMNRRRPMOPPnpnmokpmorslmmlqSUVVUPQUSTUUXTVVstqxrquvuwwvurszVVWYWVXZYY[]Y[\\{y|v|yxw}{x{}y~aaab^c\]\cba]a`^|}}facbgcdbigcdcdjjjgikmghijjohjkilrooqrqmppqsnootpfefaacbbfgcfghfeFIJHILGIMKGJLILJmgiflgihmgkhliokLMKPKPQLSMROPTQNmmkrprnpptnqnmrsTSVRWUUSRVVVXTWTsxvsrsysuvsyvuwtZ]WX][\^]Y\^^Z\[|{y~}}|}{{}}}{_d]cbc^ccb^`aeaa~~hiideefjedjhhfdkiimohpljkinlmqqnrorqsptrqsursstrhdhdgigfgjhcjigdJMJNLOMOHMKJJNKPhmkjjkkkjjjpklljQSTPQQMOOTTVTTRPotsttrnouquqtsqqXTWXVVZXXXXXU[TYvuwxsuwztyvvuuu{]Y^YZ^[Y_\]Za]Z]xzy{{y||z{_dc_eecaff`egcdahieidjfjkleejelljllomnmllmpqprqmqrttuutvpvqrsvrreffdggfcgehhjikjNNNOHLLONKNNMMQPnjihnnmnnnmnooloTPTRQOSORRTTPRVVsppssotrtortvpsuUUTXYVYZUUVU[VWVtttv{{wzw{z{uv|}]_]]\\^Z]^^aaabb}z~~~z{}}}``eeed__ceagefcfefkihglliififfknjnpppnnqqrnqppmosuqqqquvrvvusttrdgdjgkddegefifggKPPKNJOMNKKJLMQRnmppnnkrlppkopssSPPPTPVUVVTSRVXRqptrtpstutstttswY[X[W\X\WZZ[\Z[[z|ywz{}v}|||x}y}a[_\ab`\_\]_^b^b||}~|{~ad`fbedgadehbccbfmlljimhmiiglmmjkrmnrolqsntsnsomwursruttvsswxswwjejgfijihljlklnnOQLKLLPQNPSMMPNOmqpkqnonnmnorrsoUPPQWXXQWSVSRSYTvvswuxusurxrrrtwUZV[W[W]^\_]\[ZZxyy{}y{{|~|y{}z_a_``c]^bcb`]^a^||~hdddbechfgghdfdfhillikknoinmkpmoqoloqrouqutqvtrqwvxwxxxvttvvvuwwglmkinkjlhilhoilQLRNOOQQTPRONUROrmlqnsrsnqprnroqXTXWRSTTUUTTUUUTrqvststxsvstxvxv^^W_^YY]^`^[Z^_Z|x}z{y|}|~}}}}^___`]^ae^a_f_df~~cfefchicijhhjeiikhlohlpplmjnpjlpsopusvstotprprpsvtyytwxuxxuyxwvwLJQRQPLRMKPPOOMOklklmlknmlpolsmpWUQVVUSUSSTXSWTWpvwvvsrtswxuwusx\W[YZY^VZY[XXX\^w{|}y~v||~yzz}yy__\_]a]\]cd_^`ca}`fbcegbeceidijdhinhlnnljjollkjpklqrrmnopsouqvrrsvuwxyytxxwuzyztuQOKMPNRLQSRTSROPrqnqpnmsslsprnnpTRUSQQVWVYVSTXVSxwturrxutrwzsvsuVVV\YXWYXW[^Y^][|w|zz{}}{y~y|a]a^]bc]`d_^_df`}~~biibgigiidfcifiknknljjjoopjikpmlppoqptquppnuuuquuuzys{uv{vyvuy|uPSPQPQRUMPPSUPVTrnmtmnnptutstovtSRUUYXXSYUUWYUUXuryzwyxwuy{{wzu{[X^YZY]]`Z[\]Yaazxx}}y|}z|}{]cdacdcc_abccec_}~~fhecfjdhefgfjghfnkiiojlllmnpklrktorspuvpvtpqtxxtu{{wwwwvyxyxwv|zOQTOOTOSUOQUSVSRrppurqsuurvppswvZTTUUWUZ[YZUZYXWutxuvwtzuwvwyv{y_^]^[`\``^[b\\b`}z{{}adeb_a`ggfaf`hdfiiiefelilhmljjjnmpjkkknpnpqqqrsppqtutvwvtvttxuyy}zu{|{zwxx|x|~}zVOPVRUSPPTQSSXQTpsstustruxswvvsvXZ[Z[YW]Z[Y\\Z\Ywwzw|vwwzyzw}}xxZ`\^[a[\`__`b]baz|{~}~|~}ce`afgehihfgdhdfjgfifghlgnlnilkoqqolmsrtmoottoqtruqrrtxrxttxwyx{zxz}xyz{z~~|{~QPVVQVQTTRRUTYRXpspsvxtwrruwstus[X\]XZW\\WZ^]\[\yzzww{y|y|y{}|~a`__``_ac``]_^db~|~}~~bbfegbbghbcfeiefgfggmmkkimllkoknrqpqqtqpnnrpronuwtsxxyrsxystxytzzxz}x~xx||zz|{QRSRSVTXYXWTVXYZuuryrvvvtvwzvzxwY]\W^YYXXY[[][_[yx|xx{|{||}]c`cabb`c`bdbfc_~diciifeffjjdjikhilohjlonkijppjoonnpoptquprtqrvwpwvtzuuyz{z|yzy{y~{|}~~{|SXWYUXUTUYZYUXYXrvsxxyuvxy{yuvw{Z]]XYX`Y\^[[[\Z^~yy{{{}~~{|}}b_bcdd_afb_e``ae~idhdjifkhegjjefkknpmlopmknpqopqptsqtppqtswpurstqyvwwx{vxy}yyzvwwz{z|}~{}~VWV[VYXYUZW[YV\[xzvwwu{xuv}|x{zv\[`a_]]``Z_`[\`]y|z~}~`f_fac`fadagaahhejjgighimmkgkhkjnonmokoqproqnossquvqwsuussxvywyt{yvyww}}~|}{x}y|~~|~WTYYUUUVYWV[W^[[{u|v|vz}yz}{||}y\\`a`\b]`b``]_b`{}||~~{~}}dfaedafechcdbecehfhilkmgiijmkojjokpqrmnroosnrqoptxrusswxwwwrtxwvzz|~x}~xx}z~z|y}}~W[]\XZZ^W][[X][[{|yxxz~}|yxxx|}[]`cba\`]cb^`ada~}}}~gdcaedfhbehdjiehimhmikjmnlmmjnpnnptroqrrounttuqourwsuszvwzwtttz{{y{{}}{~~{~~~\YZ\^[X_^YYZY_]`w|x|x}y{y{{{^bb]dbdcda^`b__`edhcjjiggfigijhgnkjjplonikpnmknnprsqpttppvrprwrqyvwzyyzxyy||vv}z}}}|}{}\[\Y]]`\\^a``b\]}yz{z{z}~~{c`abdbdfd`gabefefjjjkejlfijflmfkmmjqnqooqklqlqortvpuvrvuwwsuqtvwxyv|vyvv{xwzxzx{}|~~\\\]\]_[_b_`]`bb{|}~|~~~bc```f`acehaehehhklgmllmnhmjilknmpqrrpmlspmnsnusqwuxwxtyrursyvvyvx}xz~{y{{zz~~~~]a^_`^^^^`c_]_b^|||``gafbhdhdbfbedcikgjmhhlmjkjomomsosmrssrontnrtrqustryrvtyszyvsuv{~~~}~{y~xzy|}~~\^\`b^_]`^c`_d_c~}~~dcdegdjijjjdiggeighmmijjkjjpnmomppqtoqtusstrqrrvvvystuyyzwvyzyuyy}}~|z~}~}||{}~~|~|~}}acbb`gchhchdhedcjjfklhjhgmiimjjjmnmpsqqlmqpqponptuqsurtrtvsyuy{t|}~{zz|~zzz~}}}~}|~~adcfiihdbbghkfjjhhgijhijioijkklpnqmntrnortrststuwuvuvuwzuzvyvv{yy}yyz||y|}~{}}}~~~iegfbhegfijeiefejhnljhikkllmpnomnoopquuruvvqqvwtvzyuzvv{yxyy{v|z}z{~~~}~{ijhdekdkllgkjglfpnonjnmnlqolkqosuupqoqrpttpuxvrwyxw{yz|yx|xx|xx{{|}~}~~~giijemifhhgklhmlpnlnknorqssoqomrrvqquqvxwyqxuuwv||yxy}{yw|}x~~y{{~|}jmlfkklkihlnmmliononqmsormrnrnursquyryurzsuywzyyx{z}y{}~}|{}{z~}nkkjlhjonjhkliklmpsmsmqottprspqsvxvwtwwxtttx|wyxy}x~x|~{~~~lhinpklnnkplqjnopsstqvuqouvstusw{zwvzwyyu|{w{z|v|}{}|~}{}jpjklomponmqpmnqosqtprrwpuxsswvuvyzv{v}x{}|v|x{y~~~oonpopplppprqnosptvuvwqywvxywstwx{yx|w}{{}y~||z|}~prqppmosnnnpqpqorsxxsxvwzwwvuvxu~|yz|{{~||ztmtqtosuqtpspqprzruuuzzywtyz|||v~yyy|z{rntusuqtwwqttuxqxy|tz{xx}yyw{z|}~}{}}}}~pprttrswvststyyx{{|xzv|~z{y{y|yz{~}uqywttrxvtzzvywtx}z~yxz}~{y{}{|~~~~wsyuyvzvswvwxyty}z~x|{||~y~|{~~uvrrxqusxvvutsytv{wv|y}}x~|yz}}y}~}~~vrrxuwtsxzzstyx{yy{xw|}zyzy|}|}zuvwzxvxvzxuz{{|~{|z{{{}~~¿uyxvvztzx{zy{|{w{|zzz}z~~|{}~¼¾xvzwwyz|v{zzxy~}z||~}¿x~x{{zz|}}}}~{|}}¼½¿ÿ|~{x{y~}}~{y{~~¼ÿz{}zzz}{{~|ɀz}}~~~|~}}æ}|~}}}|ľÿƿ£ǃ~~}ĬʄýĽɨ¾èʭ̈́½âȫ΁ž¿ū˯υ©̰ѢǭүĦǯӳ½½Ʀ̱г¾¾ŨΰҶ¾žĿſÿŧ˫εָþŤȬӴѹ ṷ̄ϵع¿ý¦ƪɱҶ׼¿ý¿ĦɩΰշۻÿŬ˱׳عĿ¾£˭ұҴۿƥͯӰַ޻¿ĤǫʳϴԻ¿ĿĵÿŮ˯ֳܾþij½ſſ©ǪЯس޾ĿùĿäȯҵֵۻ½¿Ý½¿ǩƫͲշ۽⛚ÿŤΪϵԺ¼½ý䚛»¾¨ȪϰԽݾ皞þģȭβδո¿ÿ硡¿¿ĿǨбԶٹ¿靠¾ÿſǩεѴ׿½襞¾ÿǬгյ׿½¾韢ƥʲϴѸþ뢧ūȰ̵ֻ¾þ饩éȭϵս½ꦦ¬ͰѺռ¾誩ĿªƶҺؾ몧Ĭ˸ӾýĿÿ髮ȯƲͺſ뭨¿DzǴϽ½髫¿ʹӾ½þÿþȹͼþýȵ̿¾¿¾Ľþôʷ½¿½¾ź̼½ľþ·˸þŽݾƿ˾¿ѿ¿ÿ߿¿ƿƻ¿ĿĿ¿Żƽ¾῿ÿ¿¾¾ſ¿ľÿÿĿƽĽ¿ü¿¿ýǼ¼¾¾Ŀſ¿ýĿ¾Ŀ翽¾鿿Ŀſ \ No newline at end of file diff --git a/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-dense-cb.bin b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-dense-cb.bin new file mode 100644 index 00000000..6121415a --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-dense-cb.bin @@ -0,0 +1 @@ +vvvuzyxz~ͽtvwvwxtz}®ɻqouttwvx|{Ǽqrptrrtu}}~ȷoqsooqrs|{|~|~ɷlnqouqrs}|z{}~Ŷnlonrpqqzwx|y|}~Ƴjolpmoqsyu{{z{}~ö¿z~z}}z|osoqrvst~|{źwzzz{}nrqqtrvu{{|~|~ƶuxzwx}}ynlnrqtrry{zz{~|Ͼxuz{yyz{ljnpnnrrz|{z}}}¿ñοwwwxyx||mkmnmlqpwwyy{}{}µ˾xuvswvxxhljlknoo}vwusxx{{}ïʺrpsvwwxvfkjmkjml~wwvtxzw{ʺqrpsrwsveekigikj|orrtvwwyʸ]abbbcefsxvx{{z}inoonnrqtwvzxz|b_aa`beetxxwyuzzlimmkmnpxvvzy{z~IJ_\^bacacsuttuvv{jjikllpntwuxwv|{Z^\^_\absussvuuugihkkljn~qwyuwxxv~X_\___b]rqsptttufhfkigmi~}}~rrttwwxx}~ZZZZ][_^omqptstsfehfghjl}z}|}~~roqtstrt}{~[WYW^[\\moqsppspbebhhfihxyz}}~rpsrqput{{{~~SVZWYYZ^kjpnnoprbdcechhjxzy{|yzorqptotrx{x{}edggjjik][X\^]a`qrrsuuwydghhhink}~rtrtwuvx~}}fgbgehfgXY[]_\a^mmqqrstuejdihfij~|~otsvrwuw~~~cdcfiefhZ\Z^[Y]]qnrpqsrrecfdhigjxz|oprsrutw}}~~}bbbeddhfWWXZ[ZZ\monpprspbechfeeg||zz{}nnmrpstu{z|~`c`aefddXWUVZYYYomlnnnosdcbbfehfxyyy{y|ylnqpoorr}zz~{{}^`^_^bfcTVVXYYYYkllmpnprbabcgchfsxvxwz|xmmkpmqpozw|z}|{{`\_ba_`eTSTTYUZWgkkimkpn]`abbbbcuuvx{wxxinlnloonyvxyz{y~^]Z]]`a^RRTTVTWXfhkjilkm_]a```ddrwvtvy{{fklnmoon}wvzxxzy|HJJMPLMQ_acaecegUWVWXZ[Ykmnloqpo`aeebfghu{zz{|}}pmqqqqts|zz}~HJMJINLO_``^cecfUZUVX\YYllnmpqqr`babgcehxvvy|yy}olmonqrry|{|}FFKJJLJL^a]^baaaTTUVTUVWlkkklkom`b_accegssuwz|zxnlljlpsrvyy{z{~{FGHIHHKH]]___aaaTUSTTUUWkhillmjq``_b^ccdsuwuwz}zjonmlrnqvvwxwxyxEFFHGEIK[Z\\`]__NPRTRSUVjfkjhmll]Z]_^a`esssxxwxxjiknmlpo~twxxxyyzFCFDEHHI\[[[]\_]QPOQQQRXeeijjkoh\[[[_^_arpsutuvvhikikkml~svuuxuzyA?FBFGEFY[Z[\[^_NONNRSQRbefhgjfi\[^^[`_`sqoqututghhijjjj{~~rssrvwwv{CB@AEFFEYXZZXZ[\NNMLPRRNcedhghhjWY[X\`^^oporotuufchhfigk{~}}}ospturuu~}OOPTSTWUHEFKHIII_]]]]__aQTTSTVVVghjllloo^\[^daeduttvyuwziillkonnsvvywwz{OQRORRTUBGGHEHKK]\[Z[]^\OSQPTVWRgfjjlhkmZ[_a^]abrortvyvxijiillnn}tswv{wxz~KOOQNRORCFGGDHGJZ[]Z]^\_NPOPRSUTfhgiggij\_]^[a`_posutuuxdfifljhm}|rvswvtxz|OLNNOONP?BCFDDFE[XWZ]ZY\PNOORRORchfeehigYYY^Z^__qqqpqsstdgfjijkl|{~}~spsrxtvu|~JKKNNNLSB>BCDCBEVX[Y[Y[\KMNMQRROdbdefeghWX\ZZ[]^nqqqpqrreeefiikhxy|~pqsstpts}|KJJJLLPN@?BCCBBDTVZVX[\ZHMNNOMQNabddbcdfU[WYXZ[^ommmnqpqdcegghdhxzx|{}}mppqosut{{~~GJGINJNK?=>>A?CBXVUSVYY\KKJLNPNN__ccdcceUUYTWVZYlnlnporpebcbfdghx|{z|~{lmqporuvxyy|~|DGKGGJJL?;<@AA@BPRVTWWYWLFMKLMMKa_abbecbTTWWXYZ[iilomopoac`bccchrxwyyzz{kkonpsppwzv{{|~96788::;KLIONKQQDBBEDEIGWXYXY\\[NMOOORORbdfegiel[[X]Z\`[oprpttrtfedhgjfl|{~|}~qprstvwv~|}42664989JIKMLMKR=ABBEDBETXZYZ[\XMMNONRPS^a`deeffWXVVZ\[_nlplqqorbccggelhzywz|}}mppqsqrt||{|}~22265596HGLHJOLL?A?ATRTTTXXXHIIKLMPLbc^_a^bbUTVVXY[]kmnkomnn`cadcdefxuwzz|}}kpnopprqzyz{}|~/3/13443EHJHGHML<<=>><==MQOVQSSTDEIGEGKL]\Z^^a_bMQQRRVWWegiijjjj_\_a`a`buqtxvxvvejkkomln}tvwuwuz}*-01-/.0D@CCGFGI:6;<:><;QMQQOQORDEDEFJGI[Y[]^\\^QQSQRSTTdgdhgiik`^]^`_^aqqsuvvvygighijnm|~~~qswyvxtx=<;=<@?@20044486FFGLKKKL;:????@CSSTWZWUVIJJGIKNO\a_`eaccTVVXVYZXljllmnoma`dccedduvzw|z{}lllnoopsvxxzz|~}:9:<<><>11-/2753FDIIIFJL88>=??>ARPSSWXWWGGHKKJLJ^]__``bcUSUVTWWWikmmjnnnab__addfwvuux{~|ilimnopoxv{z|}yz89:::<<<.,./..01EEGGHJGK9:=9@?>=NPQSPTUSHGDIIIKK]\^^`aabPPSVUWXVfhijiknm\_b``bbcuttuwxv{gkkpiqnnvuwzv|yz658:<;;=*+---30/@ACDFIEH;7<:=??@PMNRRSQUFFHJIJKI\][]_Z]^OQOSSSWUjggfljik_a`_```crtvrvuwymilokonnvuuwyzy{}588:8:<9-/-1/422?BECHHCI99=<=>=?MPPPRRRRCDGHEHJKY][_[^^aPOPTQVRTeffhhgml\\]]a^dbprrvtvtwhhikmllo{~~yvuxy{y|~331544:71--1/105?B@CBBEG7;8:==>ABCEAC99;7=>;BIJLMLOPRBEGFHHIHVXYW\W][NTSQUVSScceggchf^`]^b`_cnooortstfhhimmjmz|}~|uwuw|wwz~214455760,/01004?@>>BAAC;=;=:=>AABCDOPORSRTSJMLJMNOO[[[`]_^\VWTXX\ZZcdfjgllkcceghgefrrtstwwvqknqmrsw~~}|{}}~~&)))+),/1585:78820434668>ABEDFEFA?@DCBDCMLMOQQRQLJNMLLPMYYX]]Z[`XWUYWYX_cffigglmeccdiegfppqsrttumqorrpsr}}|~{{{~||&$*&)))*6447539744527498BAACADBEA>?C@EECKNMONMQQGIKNLMNPYYYZ\^]]TVXZW\[^eagggfghbbcfgehhpsqpqtrsknqtqsptzz~~}zz||}(&(('(*+1226588503434968?A>AA@?E=>?ACCD@IKJLKMMRKMIKLNOOWTWXVYW\TXXZ[Z[[ccedeiii_bcgdgegnmomnstrqnmmosssy}{|||~||~|$')**'+.23234766/5444878?@;??@BB=??CB@?DEIIKMMNLHILOOOPPVUUVXXZXWW[XYZZZb`dcdfdgdabecfgfnlnmnootlnmortsqz{{{z}~}z}yy|~'%'&*)+*/.10246512462557;<:>?AC?;DACJIIIKIKLMJJJLMNPWSUXUYXYUWXW[Z\Z^a`cccdd^bcdefegjklolmonlpnrqusnyyzy{{{{{|{{~|}$(%*&'+)-//0364142365876;9=>=;@??=?@?AEEIFHGGKMIIMKMKJOLQQTRVVXWSUWXXXY\\^``^`baadddbfcekilnlloookooqptsuuv{{z{|zx{|{|}%*&+&)(+./,0/004053438678::9;<;<@?A@?@BBFFGEJHLKGGLNLMOOQOSUVSWWUW[YZZZ[\\`^^`bc^feefdehjfjkjlloomnpqqrrutuwyyx|w||{}} \ No newline at end of file diff --git a/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-dense-cr.bin b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-dense-cr.bin new file mode 100644 index 00000000..d05ad51a --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-dense-cr.bin @@ -0,0 +1 @@ +tttrwwtwxz~{|}{stutuvqww~~x||}~¿Ŀpmtsrusvvt|zz|~}pqotporsx|wy|y~}~~~orsnnppqxwxywzx~~mnqoupqqzxuw|xzy}}olonspqpwstxuwyy{~kqmrmoqswrxxvwyzy~{z~~~}osnpquqrzwu{yyy|~{nsqqsqvtwwwyvy|z~|}}olorqtrquwvuvzwz}~}~{~mkopomrqwyxvzyy{~}}~~}~}~}pmnonlrpttvuwyvy|zz|}y}|}~jolmloppturpttwwv|{{}zw{}~}|hmlomlomvutrvwtxyx{z|~|}zzwzy~z|hhnkiklkmqprtutwz~zz}|ybfggghjjy|~joponnrqqsrwtuxz|}~iegffgjj{}znknnlnnpvtswvxvz~}|}}~fbdigifh{}zz|}{lmjmnmqorvsutszx{z}z||aecdeagg|}{z~||{jlkmmnkpovxsuvusxxy}{|}_gcffeib{y{x||{{ilinkhokqqsruuvu~wx~||~|~bbbaebfexuyx|{|zjikijjmn~qnptqroryuzxz{|y~d_a_fbccvxz}xw{wfiflkilk}}~spsqpntswvvyy{z}}}[_d`aaaeuszwwwxzhigiflkn}~|}osqpsnsptvsuxzz~|{}~~~qpssvvsveb_bdcgfzzyz||~fjjkjkplprprussvx|wv|}{|~stosqtqr``bdgchduuzyzz{{ingmkhklnsrupuruyyzx}zx}pqpsvqrscebfb_dd{w{yy|yzjgjgkljl}~opqrptruyxyxw{{{pporqqur`_`bcbacwxxzyz{wgjgliihi~nnlqorrswvxyz}z~{}|~oqnnstqpb`]^cb`ayxuxwww{ihgfjilj~~}|lorpooppzvuzvuwz}~}~lolmkoso^``abaaavwvwzwz{hfgglgljy~{}||onlqmqpnxsyvyxwv}}oknqpmns_]^^c^c`rvvtwuzxbeggggfh|{|~}}|kpnoloonwsuuwwtz||z~~nmilknol]]^^`]`arsvtsvuwfchefeiiy~}z|gnnonqpnutxutwux||~|{~UXWZ]XY]mopnrprt^`^``ac_uwxuwzxveejifjjlz~pmqqqpsrxuvzxyyy~UX[WV[Y\nnokqsps_d^_adbawvxvzzz{egfflfik~|{~}}qlmonprquxw|x{xz}|{TTYWWZWYmpklqoon_^__]^^_wvvuvuxvfhdfghikxy{}|pmmjkpsrruvwvwzv}z|~~|~UTWXVUYUmlnnmoon__]]^_^`wssvvws{ffdgchhiz|}{}lrpomtnqtttustts{}~}}TVTWUSVXkjlloknmYZ]_\]^`vrwurxwvd`cecgej{zz~}~}mlmpnmqpruvvuvuvy{}~}}zVRUSTVVWmkkjmknk]\Z[\[\cqqtvvvzrcbbafddgyw{|{|}|kkmkmmomqussurxv}{x{~}}~QNVQVVSUjljllkmoZ[YY]^[\nqrtsvptcbfeafef|zwx}{|zkkklmlllqqrptuusu}y~{{yz}TRPPUVUSkikkhjjk[ZXW\^]Wpspustsv_ab_cgedxxw{v|}|jflkilimnrortprrywzz{|yz~~bcchggjgWSUZVWVVollllnmo\_^]^___rttwwwyydb`cjfki|{z|{|kkmmlponqsswtsvw{xy{|}|~cegbfehiQWWVSWZZmlkijlmjZ^\Z_`a[srvuwrvxaafgcbfgzvy{}|~lmkknmoosputxtuww{|{{zz}`ddfafbfSVWVRWUYklnimmjnZ\Z[]^_]sususrttdfdeagfdxw{|{|{ghlhokioquqvtqvwzv|y~}|}eadcccadORTVSSUTlihkmiik\Z[Z^]Y]pvrqqstra``e`efe{zzxyzzzgjhmlmmnroqpvqtrwzyyzz~}_aacccahSNRSTSQThjmjlikkXZZY]^]Zroqrrqss_`dbabddwzyzxxzyiihillnj}}pprssnrpyzwz{yy{b```baecQPSTSRQTfhmgilmkUZ[[[Y]Yopqroppr]d_`_bbexvuuwyxyhhilkkfk}|~mpppmrsrwvzzzzx{~{^a]_d`c`QOOOROSRkigegjjmXXVYZ\ZZnmqpqppr_^b\_]b`vxvwyxzwjggfjhjl}mmqporuutuuw{y|v}}}~[_c^]a`aRMNRRRPScdifjhkhZS[XYZYWpmpqpsqo]]`aaabdtsuyvyyxgidghgglx~}~~~llpoqspntvqw{wwy{z}~|NKLMLNMNaa^db_eeUSRVTTYVhjihilkj[Y[ZZ^Z]oqsqsupxdc`e`cgaxxzx|{y{jigljmhnppqrsuusyzvwzy{JGKKHNLM`^abab_gNRSSVTQTfjkjllmhZZ[\Z^[^lnmqrqrr_a]]adafxtytyyvzfgfjjhpk}{~lpooroqrxwvwxx{|HHGLJJNJ^]b^_daaMURQRTPUhfjklgkhWYWYZ\[Xomoqnstr^a^`bdbcytwwyz|{kfggjklj}}mosmrqqrvxxw|u}{||~GGJJFHIK]`_]_^a`MQRPPRPQgeffejiiVVVXXY]Xrrkmoknn_]`_aaceuwwuxvwueifighhi~{|~lpnpppqpwvvwxwzy~}FJEGIIJH]`b_^^cbONPOOMPTheecfgijVXVVVX]Yllpnqqor\\^Z]^`bsstssutycifhigih{z}~lpnnrpppuvwuxtyw|}}|~{GHGJHGJI[]]^_^c`NKRMSRTPecddficeTUVWWV\Tpklmkqqq\Z]]_^a_vsvxwzvxgcbegihh~z|}|~llkppimputyvrtvwx~|zCEFFGFIE]\]Z``[\NONNQNOOaebjdffgSTXVSUYZmlimmpmqX[\\\`a`psusutssebegffeg}xz|~{{flmmpnmnrtuqtqvzzy~}~{BEHIDFEG^X\[_]_aNIOPMQNMfbeebdbeTTSTUYUWlikmnkjl\\^\\]^^psosrtsuhecdgecfxxz|}|}ikhjklonoquwsupu{||z~~YWVXV[YYIGFKJJML^]^cab`aNLQQPPPSfffjlifgWWXSVX[[jonnsopp]`_a^ab_wtvuvwwugfjhhighz||mmlnooosrttvuxywz}}~VUUWVZVXHIDEHMJH^\aa`]abJJPORPORfcffjjiiVUVYYXYVnlnmnnpq`]__]__`tuxxtxwwhhdcfjik~|{{~knjmoppnusxvyztu}z}~}}TUVUUWWWEDEEDCFG^^__`b^bMMPKSRPNaddfcggeXURWWVXXmkmmopoqZZ^a_aa_rstusuwvbehfegfg|z{{}~{inlrisnosrtwryuv|z{}RRUWXWVYBCDDDKGEXY[\^a\_OKPMPRRQdabfefdhUUWYWYYVlmklnhklZ[Z^]]`^vsrpwurufhgeffehy{~x|{}~pknqmponusrtvwvxvzz{~{~|RVVXUWXUFHFJFLJJX\^[aaZaNMQPPQPRbeeefffeSTWXSWXZjnkojmmp\Z\_\a\^qrrssrxwdcddhdkhxzz~{}z}kklnonnpxusvwywyyz}|}~QQMRQPWSKGFJHJHMY[Y][[]`LQLOQQROaadbdbcdUVUYW[XXhfiomhol`\`^`^_`qrsqqpvshedghfgjx{xy|xy}mmkmqttqttwuuvwxz~||~~}QPURTQTUHIHFKHHIZX[[]_Y[OOPKRSOV^_bb`cefTVXWYYYWhjjhmfmj[b`^ac^^pprttosrhiefkhfkwwwwz|z{jllmqqlpuwtv|vvy{|~|}~}QPSRTRTSLGIJKIIM[[XW\ZZ\RTQSPRSQ`be`_fb_VXYXZ[ZXdfihkkkj\^`a^^b_posqrrppkhjghglhxvx{x{w~nknorprt}utuxvxwzz}~~EIECHHEKVSTVUVXYJMJPRNSN[\[[\^^]RUSUVVWXcecfgehf[^\Y]]^^lkkplomjbc`dcgddopqurwvulkmopnkmzy{y{~~|vnqupuvy}{z||}}DHGGIFIMORURWSUSMINLMOOPX[[^]^\]XUVZXWYWb`bdeefe][`^]\`]jihmmijoedbebddkosrurrwynlklqmnlxxyzx{{{qvsvusvt~{zz~{{~~EBIDGGFGURRTROVSOOPLQNRQ][[\Z\Z]XTUYVZZX`dbdbaeeY[]_]^_ajjjkmnmlbcehcigjrnstsqrslkloonppy|yxx{yzpsvyuwsw~~{{||~~}~IEHGEGHIPPPTSVVRLOONNSPRZ\X[[YX^TUVXZYZU_a`a`bbf^a[]^`a`ieiifiglbfghihhgqprqrvvuhllqmqnoxvwuv{|zwtsrsxww~~|{~|~DHIJJFJMRRQQSVTSKROPOSRRZ[UYXZ[[UWV[ZVUZ\`_acbc`[\_bbacbigghiijiffjgghggqnrpqrqtpllomopowvwuvwv|rtsuxzxu~|~zy}IFHFKHKIOMPNQRTSOOQRNPQRVWUXY\^XSTYTV\XZa``_a_aab^^]``abjegjgkijeghfjikglpnqqqqpimnoppopttvxuvxusxtyx{ys~}~}}|~FJFKFGLIMOOORVSOROPSRTSRXUYYXUZYYUWYWY]]b]_^]ac^^b_b^^b^ddgehhjidfhghhhjkmnnkmomnqpomqmpvsvyuuxxwrvwxw{y|{|~~{}~~~~HMHMGKHKOOLPOOOSOSQQPVSTUVVUVWVWZY[YXYZZ___\a_cb]]ad`bccebfhieiifhljjkjjkkollnpqjsrqrppstpuutvvxxuvxyyyy}z{|~}|{~} \ No newline at end of file diff --git a/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-dense-y.bin b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-dense-y.bin new file mode 100644 index 00000000..e0cc919a --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/ref-dense-y.bin @@ -0,0 +1 @@ +/525.51//673344648948;69779:77<;   >@<<>@>>@B=A<=BA !%% #%#"#"'"&&B@CA@BCFFFBFEAGG(+$*(,,&',,--&-(LFLKJIHGFFGLGIJN+..-.+0-.,10/-3-NKQMONMNPNLRPQRQ1176034158481935WRQWQSQURVXURXUR9;78:<>;778><===V[WV]ZY]][WZ]^]ZA;B=?A?@AB@>>??A41/42451663331344778;7:599:78:==!! " !#;=;=:==B;=A??@B=$%# !'&!#"''!##)ABB@EEDHCBDAHHED&,,')+()**.((,.(JIFILHGFLKHLNHNJ,.//0,-.1-30-002MNNNPQLRLMPMMNNT625822622962:599SVUVWYWWUSRSYYXW=:97:7==>??<;<>9[X]X\]ZX]_]][[]Z?>==>>A>?A=A>CBA246836166735629368:;;<6:9<<;=88>""! #  #?%'#!$"#"$(#'*&&*@FFDFDECHDJFDJJC(*(*.',*.().(/**KKHGKIGJNLIJIJKO12,.1--.-002.563PNMQNRUOQPQSNTTO496673899748;57:WSXSZTVVUXWZXTYX98=98=9=9>;?@:=>AAC@CADC@481778685689339: 98=>==<8<;8>>>?>$#!" $$ AA?>?=D@E>E?>D@D#)#)%%&(*'))('&*FBGECJHFHHEEGJHE,)*+,.)00*//+-++MJKNLOJJJNLNKJJM.1/100430210//76OORQVOPTTQSURQPX3:4868;5967<:657YUZWWWYZX\YZZ\YV=@>=<@;:<:=AB=<=[Y_`\^`^`\]\\[]aDD@DBDDBDCCDEEBA3:345677875978<:  ::98:@==9<:A@@<=%"$"#! !$" '"??@>DBAB@CAB@EBG*($(*$(%+%*,)*')HJJDHFHJEJGMGFIJ)-.*+**,.-2+.02.NIINNOQPOMLRMLOS4432242517838354QTUTVSQSTVWSWWTX79:6<7;77;:=7<8:YYWZ\XYZVXZZW][Y>?>>;?>=>>@A?@<>\]_`a\\b`]b`]_bbCAEDDA@AGEGEDBFB4654:;5:67;::66:  !9;::=@=<>AB:89[ZX\XZYX[[\\]\YZB@?A;<<>>>>@D@A>ab_a]a_a^a`c_`^^C@EFCECEBFBFGFGD6:;;76=<7<68:::= !"# <@A>@<>=BA=@CAD@&#!$#%!"'$(""%$'BCA@CAAFEGBHHCCE(-,',+-(-**-')..JFHMGKGNIJKLHHMO0.2-1/0.3-1/3010PRKROOQRQRMOPRQU7656359653387644TWRXRTSTURVUTU[X6;7:9:;??89:??9@XVY^YZ[\_Y\X[_^ZAB=C]_b^_b^a_a_ddcc_EHGEGHHIHEEGIHFI8:799:8<889:=88;"!!!!#!#$@>BB@>C?C@D=BBC?%%')&'')%$%*&()+DCHDCECICFDECJGI-,*(-,-(*,.+*.)-HLKIOOMHOIKMOONP.,02.14./4202052NMTOSPNQURSSOPUR25938375654;49:7RVYSTZZXTTWZ[U\Y>8<:=8?>?@:?A?>>]Z\Y_][^Y_[__a]`A@AAEAD@A?AFDAC@c_b_dcbdbebfafdeFFGGFEIFJIHJHKFJ8:;=?;8:89=9:<>;#!  "$!!#"% "?@?@CC>E@AADDACF"&)($%#))*&&*+*&JCFEGJJKJGJHLFEE.)+),+.)-.0-*0/-MHPMJPPLJQLJNLLO0210635626373736URSPRUSOSPPWVQPR7587;;757:759777UTZ[XZYWY[]U\ZW[><:?;:;=@A=ABZ`\^`_[`_a[a`^`]@@FEC@C@BABFECFCd`ccdafbgedadedgFEHGFHKJHLHHIFGG<=:?>?9?=:<=?:;?!$% !$# &#"CCBA@?@E?B@E@BEG*')*)$&'(*%%+,*'IIDJKDJLHFFJHLIH0++-/-+1+,*,-21.NNIPNMMKPMQKQRQR3206516626536235TVRTQSPSRRUUUTUU48:;98;<=:;=6=6[_a[`\\`^b_^\cabDCFFDAGAAHHFGDFGdddddgdcacgcdbchJIKJLFFGLHGLJJKM;;@:>A<#!$"%"% ''&(&""!FDFDEAGBGHDAEBEB%)+'&*+')('()',*JIHFIHFMKHLMHILK,,++///2.,01,1,2KKJMRQNORRMQROPT5366422862362629QVTSRURXXSWTRUVU8<57;98<87>=9?:8XWXW[WWX[[]_ZY\\@@=@=@>=DBBDBAAB`bababca_aa`b_b_EAEFHGCCGDGIEIHFcbcbdeghbefghdggIHLLKKLLIJNNLMIJ<>==>?=ACBC?>CBC''!$#%"%#('#)"'&FGEBBCCBGFEHIFFG('*(**-)+),)+(+-JIKGMKGNKINHIHJO00/,0.4-20233320RSNSNRMOTNRTUNTT71889842469848:9SUSXXZSVSZXWTXVZ9>:=>:=?>@9>9;=;^][]_^Z[\]^_[^[]AA?D>>C?EDBCF@D@`_]e`e`c`befab`eIFDFFHJFKGHEJFEHhfifjdjjggkkgghfJMMLMMJLKMLLKMOL=C=AC=>?@C?EC>@D#%%##&)$%%&*)(%*HIBEDCGHDIHDCJHG++'.**/,-*)/-+/1HKNHOIJPMNNKPOLL2.001542./542242RSNSNSTSUSSURTOT5874:867477:<<57YYZYTTUT[XY\YYW[>9<<:>?;>?>><;A<[\YZ_Y_[_^]_a`]aDDEADBAC@AFBC@GFa`_bda`ef`dafddeFEGEJIELIFGLFHIMhdjieigjgfjkfhikKNOKLNPNPOOMNMLQE?A>E?AEEE@CCEAB&&*()(&%%,$&+*%&DFHCKDFJEEFKJJHK,--0,-*1,..*0.-.IMMPLNNKQJOPNKNR0020113004246567PPTURQWUUPQUWVTS755<57:6:8:;766;XV[X[YY\V\V\XV][>:<<;;@A?B@@@>B>[`]`[]``a[]`c]^a@EBGD@BBDCGDHEDGadbccbcbhhabhhhgKKGLELHLIGMGGKMNjifjjjhgimkmkjmhKOPPLNRQQNRPNPONA>EDAB@E??B@DFGF&'++%)*)'(,'-,))GJGFKKIJIHJFJKFJ0/-/0/10.+.+2,--MLJOKNPPKKRNPNPR1364661173327687SUTSPWVTQVXSQWTR9:5689:767:<<;7;WXX\\\\]ZZXYWZXX===?@;AB=8;?;?;<]\^W][XY^^ZZ\[\\<@B>B>>?ADE@?C@A\c__^d_d]b`dbaceDFGGCBGHICHGIJGFicdicgghdjgfgikhGGILNOLMHJINJMQMgolioojjopjknnolPRQSRSRSSTPPTPRR%*)+&&+(+'%'%)'-GDJKEGKHFILILIKG*/-00*-0/,011-.2NOKNLNLPQMKQQMRQ2243601351167416UVPQOVQTUTUVVXUU875:775:;<<:68==VV\U\VZZW]\X^]YX;A@<@=ABB?CBA?>B^\\][\_]^bb]]dabDEDDDEEGGICDHDHEgdbaefefegighhgdIKKJMGINMHGMKIJLhlflgmimjmkjokkjRLOSOPQMNPOSRSNNpklpmrmpqmrqqrnp(&+*)&((&+('()+.GJGKIGGGJJKMHGGM,.-,00/-2,-/1421OONPKLOSPNMQMQMQ5036483163557487WRTVSUYQRXUVSSUU<:9;;==>;8<8;=98]]Z]Z]YX\X_^Y^Z_B?C=>A=DA>>>@EDE_cac_^cb_`b``cb_HDHIFEFHHBGDIIKIhfigfhcdggefkhghHLKNILNMJOMIKKIJmmnikkjmklinnokoNPMSPRNNTPOTURUOrqqosrostssstttq)&&,)(+,)(-'-,./FJJLLGLJJJMHNOJM/.1/3./02412-./5MRMQMLPRMPMQRSUU54115539847847;4UXUXVUSRVXSWVVX[6789:7=:99>?@<:?>@AB?@?@DB?a^`da_bca`beeccfIGDHCDGGIFGFEGGJbdiijjhfhjdieddhNMOHPMJPKNMMQNMKjknomkmplkjoqjprTPSTUORUTOUSPPUStsnponpsstrssspr((-*,*(,+,/+,0+0KJNNMLMJOONKNNOO3-.12024.10.5/00QNNRRTRPNSURVPUV8438:567;88879<6WWUZVWZYXZZZWX[X?<=<<9@>=<=@<:<<_]\]^_\`[_]Z[b[]>D@@D@?DFAEFACBC^c_cad_ccgg_c```FJFCKFKHIHGLLKJJedghgigekhhigilmKKLQIOKOPLKRLNPQolljlqpjpmrnrqllUSUOTQSVTSWWQTVQtrpsqrsrttqttsws+(,.)0))***1+0,*MMMHKKNQOMQONPPM..44250146417502PTQTOVPRSPPTPTQT5:998<59<<<<=76=S[W[[VYVVZVX[\V\<=:=?@?;<=^_]_a[_^[^``^_b^@E?CADCCBAGEBDHHbee`ead`babfafceEKGKLGEIFGJGLIKJekfkljkklklgmkghMKLQLNQLPKPPQPMMpkqpnslrrpllmqsnUSUTSWQWTWVRWYTTppprtquvuwxtvuxw)++...0+-/.311,.JPJMMPPMOPORMQPN//66276712858384PTQWSPRSRUXXVSYW958666;8;<=>=<;:XXV[[]ZYW]XXYX\_<>;><\YZ\\ZV[X^[\_]XZ>;@BCC=A>DB>B??Eac^_``acb^]bcc^dBBCDHEBBDFDIIGFGddfcbchfbgfjjhijJLGGHKJIINOIPNOPllkimhjjkkmnioknQQROSTPNTRQPVRPVnprppponoptvuuppUXWWUSYXWZ[ZYYVZvtuwwxxtwvztuwvz.3-02-20.0/10/13MONMRORRTOQNUUNT1716465983756:67XVYXYUSYVYYWXTZX=>=:;>=88:>:=99=]]Y^\Z[\Y[Y[]\]^?C>@ACAD>C?ABEDDa_b^ba^e`affaeacIGDEGJDCHGKHFKKJfcddhiehiedilfklNNLMJNKPLIOJJNOQoiolppjmnjpomkpkQPPONVOTPRTTRUQTnosssutsvvpsprtrSUWUU[YXZ[W[[VWZvzwxwxxvw{zvwyxw20.3//34251//636QUSRSQPTNURPVQQR897444565:758<89UYWXYYVXYYW[YXXW9?:>UXTZZVYWW[\[YY]]@>@;>:B;@?@B<=B=[^\__a[^]ab`^bbbE@DCE@@CEFGDGCHIbaf`cbdaechgfdfcHFKHLFGJLHJIIMNKgklkmigmmllkhighQLLMLNMPNLNOMONNnoppqopsmrmrpsptSVSSRRUWQRXVXYTXtsstssyuwvtwyxzt[^XWYXYZ^X^\Z]_Y|yxyxzxz|zy}z||}7747660317336856PRWWTRXQRTXSUSTW<;:876:97<8;8=8B?>?>=E\[^^^b\`ccb_`b_bEFABGHHGICHDIEDHghcbgcbdccfeicddFHNKHMMIJJNLMLIIigkkhkjhnokopnokNQLORTSRQTRTSQSSoqqpmntpunussrnqSVUWXWYTTSVUWUZVvrwzruvtvxywuzz|ZY_\[\X\^]^^\`\Z{z{y~{|}}||~~}}|4851441584922484VSRXXRVUWUYUYXYW<=:679:8>?<:;<:<[^\ZXWXWY^^Y\\_Y==BBA=C>??@BCAAD\\b`^_]cbb^badeeGGGBHCEHFFIIFIDEbbbfghehjcjikfjjHIMJIMIHMIOIMMJKinjkmlnljlikllmlPRTOMPOOOOQPPTOSsqsqtnttquuvqtruUVSVTWTZUVUXUY[\swsv{xttxzxxx{uv]Y\][Z][ZZ\a_]\`yx}~z~z|~|z{~~932392:998:8::48USUVUWXZT[XUYVVV8>98=:<==;?@^_]_Z]YZY`__]``aA@DCDE?@?B??@ECFc^d^_db`cdcecebdJHCDGGDEJJGELEKKjggeehhfjghfjllfNOLNPLOPMPJQPMMLmonniqnkqpkolpnlRTPQVPURVRWTQVPVrsqrpsvtpwrrpxvtXTWYUYZXUVXY[[Z\ywz{{{xvwx}v{zw|]][^`_]_`\[_b_^_{z}~{}~5537945698955<:8SWY[YVXZUZWX\WV\=;@?@A:=:?A?_\_Z_^\_`_b\^[[bABBEBDFC@E@EBED@_e_`f`e`bdbfbaeeDFEIIHFHLFJGILLGfikgegfgjmijnilgNNNPJMPKPLQMMQSQkoqrlnnlopqmmmrmPSQSRPWSUQUQVSXUruutwrswwwuxwytt[\UWYY[XZYYW^XY^wvwxyy{{xwy~{zy~_^_]__ba`^]_^bc^~~}~6;<55755879:88>7W\YVZ]W\ZXWYXY[Z:?A:==A>;BAAC:98@BBA@CA<@CC>=A[]``\\`a_ad_a`ecG@BFDCGGHGDGCBEDdchhcghgdijgdjdeFHHJIKMHLKLHNPLIhhlmmjjhilikinnlLQRMRPOSNOQQNSVOosnrosqontotvppvTUUYTSYUSSVVUVZXvurvsxwt{sxu{vx{X\\_[[\_Y`^]\Y[`|{|{zzy}{|~~c_d``cb_d`eacaed~~VV[TZWZVUZZZYZ[]::;A;@:>B@>BDA?cb]^``^cb__b^``eEIBCCHFDJIIDIFIJhfbfebjehjjgidjlIKLMOLJKJLLPNMOPonnijklnjpjomjloOOUOOUOPTORQTURUornpqspoutpsptsqWSSYXUWYVTVZXWUUuvwzvvxuyvux|v|w]_[[\[`Za[][[b^[}{{}~{{|`^fd`dbdbdc`e`fdjhifdiiggkggjjhh^^]Y^\X]Y\^[^a`_?ADBDDDCB@?ABBED``b`cce^^``eedccHGFEIEGHKDFEKJFGiejejdfjgekghjjiHNMONLMJPQNMQOKPklmnjpmkojnqrmrkOQSTNROPPQVVUUWRqtqtpqrsqwvuwptsZVS[YZZZVWWX[W]]wtvyuuuxuz{zvy|v[_`^_\[]^]Z`^b^_~y~~{|~|~e_`ccdebedbdddhfhhegghjjkkglgfjj^]`[Z[a`\^_a`\^\D>BE?@@AD?BDCCFEe`ac_aabedf`ffeaGJEGIELKGLFHKFFHekggiehlfggljlkmINPJKQQOJLLLPMPQknoqlrkqqkqomlsrPPRQUVSWQSWXQTUXspqrqrrttvrwvwtvVWU[WWV[[VWZ]X[Y{w||v{w}wy|yz}}`]_]b^b^b^`b^`ba{{{|}~`gbgaaaggfahgieijhgkjkjljmkklmnh`^[Z^a``b`ab`_cbCDFAFBDDFCAGBDFHf`eecdchcdedgghfHEIJHMGKMKLMGMLHhflmhjhlkmggonhnKOPLSPOSORPNQQPRopsrmlqqmoonsrrnQRVTTUVUUSXUTVTYtxwrxqysvwuyrvs{WZZYWY\Y\]_Y]_YYx}||}~z{|yz||{|b^_bd^a^ca`ca_eb}~~gcffidiiheecjdfkgkjlkihlnnljinmn\^b`a\`^b]`^d`bbDFCGFGEFIIFDFIGHddhhfbggiheigfgjIMLGLIIKKNILMLPMjilnnniijmohlmjmLNNOSTQNMNMTRPORlplstmpsqqrpttruWYWVWSYYSTTWVVX[xyvsvtzxvuzz{yuz^\\^_[X^Y_\^`aZ]|xxy{z{{||}{~{`_ddbaae`cdcfd``gfejjdfdeegfehfemninokpmommpknko^b`caacacb^_dccdFIIHGCDFHEDKEGDEehdefgfhdijfdfkhHOJMLMOIPKOMMOOQiiolpoomlqpopmmkSONSTRRPRVVUSPUSqpnrposvtpsuvwsqZZZYVUXTZ[\V[YZXwyxwxvx{zv||v{{|_Y^Z_[[[^Z^a_a`^~}{|}{~{~|_cadccedgg`addgeifllilleljiifihijomnoonlmkoqpnom_^edebdcbcedfccaDHEDFIDGDKELEIGEehckifkkieeelkgfMNKMNNJOQMKJLJQKkijnmpjnnnmmqqrnUNQORVTPUUTSPSVTtvqqoouutvuvvvrrWZV[VV\WW\[Z\\\Wvzwut{||wu|wzyyxZ]Z\]\`_ba`b]]]c{~||}~fffbeggbecefhccijfjjfhlglinkkmmmmpqqppqrlmmrprmsd^ffc_eegfafhgcaIKHLJHGGKJKHGMKLeiikgfijjijghlgmMPLPQRLNOPMSQSLMppnnloopprrqoplrRPRVUWRRUQSVRWVXuvrqwsxvxqttuxxxVWYWW]Y[Y\\\X\[[v{xw{y{~}x~{~zyy]^\__\b\]c^bdad^{}}}~~~egahhdbicdgejegehkmgggjimoiomijlmqqsqoprsprpqsqpdadfabafhfcficgfHLFHJLIILKKNOHJHkiigmjimljklkkloPQONLSQMOSQPSNPOnpollpoqoqooooooXRUWSVXUXWUVVYTUqvsrxvwtwvzvyxxu][\\W]YXXXY][]YZ}wz}z}}{{{z}}_`\bd_dbecabab``}~ibbebeihgehkgggilknjjkollplnopplpsnrqsrsrrrossspfhbggichdifehjhgJGIILIKLLNLILNOKnjkmjohlmkjlnnqkSNMPTORNPPRRPOORspoprpurroqtpossVXSSTZVYUYTT[UVVwyuuxutvwxxvwzuyZ]Z[][^]`\]\][^_x{{z~~zz~^___ba_b`edbbcba~~ijdkjgfjfkjkhflgjppmojoqpqknroqputsptusvvuuqrvqtgibibgcihhejijidLJJONJOOKMKOMJKLjjkolinokpjooqprNRQNQRSPOTPQVRUUtroruqstopprpwtpYVVYSWXVXU[WWV[Xussxttzuv|z{v{xz\^\\_ZZ]Z\a[\b_`}{z~|{}{}~{}`_fd`febceaafbfgdkjhkfklhjhkhnhlppknkokonnmpsrpnrprssvtsvvvvtsrvhihdjifdlljffglgMINJOOQNNMJKPRKNimknjmopnpnolpllNRPRPQUTTTWVUVURsustustpvrpqwttwYZZUZXXV[X][Y[YWzzyw|x{yzwzx}x}|a^_`[`a_b^ba_a^_~}|z}}~|}eeddedcfhbdcecfghlgghhkjjhlnhkolopmplpopotpnropquuvvuursruxwtssveefhlmgifhhjlmniKPMMORKOMKLLMSPOoqoqkomnkmossppsSUQRRXTSVTSQSUXYutuvwxsuuxtsryvy]WY\W\XY[^[]W_XZvzyxwvy{|~{{z}``[``b^da]badbdd~{|}}~}}cfdgfgbedgfecjefkilgngiklijlnjkomtpssqtupototrspvuxxvuvuvywwzuuufhmghnnhmoiniinpNLLMRPPSOTRSPPUQlpormtssrnqutnuvSTYXSSZVRZUTUTY[wtxxrvytyxuvx{utZZ]XW^[__\Y`Y_\Y|w|}zzx{y|~}`adc]daddfaacbf`~~~~hghdccegdikjhedfmlljkjoompjmqpnloptotonuotvuqvttywwzvzyzy{zzzzyyLJLKNKQLQQRNORSQpppkmkopmqmmotrqPSSRQSUQTWXWWYYXqsvspuvqvwusyuxu[[YVV\]Y]XYW[Y\^|vvxwwzwzz~xz||{````^c]]a`cb]db`}~|~}deeaehfbbddfchffjmimgmhnkhmhlilmormststrqsnuppsosusxyuvwtzxtyzuzOQNPOSNPSNPSNORNnrkrllolmsrptomtUWUVXSXSYRSUVVZUqrqvtwxsrsysxyut[Y]Y^WZ^Y^]YZZZ_wz|~yz{||z{|y|b]b\]`cc^ccedd_d}}}}~~dgihieihgcjhfkfeijoiihklnkmpkmknrsqqrtuqqossqsptvswtwvxyxxyyv{vwRNRPMNRQNTQSPPPTsqmrrnrusunsuvprTVURTTTVZUUZYXTXxuyxtsxzvztuytwyX^YXZZ_`_]_^`^\^xz||z~|z}~|d]`^a`aabc_adfad~ghdgijfjifhhkhgfmloiniojpjkknkmouususrwtvtsrxwsvtvuzxy{{vwz||vv}RUSTOQPUPQPUTPPRnnosouqsttpvwsusSTTU[YYXXUYUXYZZsxz{vvzwvxwxyyyy`^_`a^_]^^``\a\`|{z|z|{}cdee`ab`ce`ecgdfjehgkeeiliifilknmnlmnnnqrpqrlnsnotupwpttwvxrsyuv{zvy|{zw|z}}x}y~VQRVRPQVVWVRXVTQvvtuqvwvptsqxsvvY[UW[XZ[WV[YX]W[zvuvy|vyyy|{w}~Z][[abb_b_c_c`]`z~}}~}~``acgfhghfiegcbcmkihgjimhiglnjkpnmplorrppqqmssrtvtrrttvyyrtzytyzz}}zw~{y}~}~~|TPRTUVXTWUURQSVYpuvwutvwtxrsxrut[ZZY[\YW^[[]XZ\[z||yyyxx~z~xx|xy[`^aa`\_cccbc^`e}~~~fchefddgfhceggeclmgjimlmiikhhnljpmmonrmqrqnsporrxrrsvyxstuztxuzt~z|}yy|z||z{{||TWUXWQXWTWZUXVZTtrqusvyxywxxtvvw[]^\Z\Z]\\_Y_\\[{x|}x{{{xy}{yzcbdd__`daacdeab`}}gcehdhjjdjjjifhhihnnjlilppjmoooprsoptsstvvqpuvpptsstxtv{uzvxwyy}}|y|}~|SYWRVVTZXXVVZ\YXxxwvyvsw{tvwt{{v\X\Y^Y^]_[^_\_[\z}}|z}~}{z~}ab^bcb_``cdeefdb~~hejifeegjhhjlggiijimojkqkrnrqpopqtovvqvrrtwruuuwy{vvuzxz|wwv}|z|}}}}|||XTY[YYUZ[V[ZWXVXz{zzuzvwy|wxv}vz__]_^[``_]]_\ab]|}}{|~}bc`fafebahadcgacgejkkifgffhmfmjhqnqnpmlmqrppsnnsttpsspqrvxyrxxsx}v}y|w{xwx{x|{|~~ZZV[UUUY\]\[[^\Zyxtywv|vz{~zxxz{``]__[[^_^\a^]d`~}~|}}~ebbfagccgbebecccjfflihhilhklnklkqqqrqonpnlsomtoqrvtuxutvvtyxutuy}yz~}w~}|yz||{z~~VZY[[YZYYY\[^\]Y|{y}|~x~}}}yyzyz_^]ca`a`bd_^b`ce}~bcbebbfddgbhieddjgjghnnhnlnikppmpomsrnuttprpuqttxywwvvuvsxzuzuvvzyz|}xzz~{]X\[YX[YXX]Y_^[]yy{}|x|y|~c_aab_d^cbafdfbd~echjidffedkggkhekkplnpjkpnqljpqntunqrprpoopurpqtwy{yzutz{vwzvxx|yz}~{~|}[`Y\[^\``]Z]\`b]~|{||zz{}b_cdab`acfeadedgffgffhghikkflhhgmnjjpkokrrplsornspvspwptvwvtsuvy|yyz{x}{||{w~y~~}}}`^\^Zb`b`^]`[`cbz~~{~~~baebcgfagccbhhcekijigljlmlkggihjnolrospqrmmrsrtmssqsxyrrxtxvuuxt}w~|w{yz|}|z}z}}~a_]a[]`\]]`cc`a]{|}~~acbehcafeegfgchfjgjkikjiihninjiolonmosmsmrrortqtttxxqyxzssxtvuwtxwz~|z|~z~z~~~}_aa_^^_a``__b___}hadffidjdcdhgehekglnmlkilnonppoonmmtpssutprtvsutswwttuytuyxwwyx{~}x{z{y}}{z}~~}~||}~~~}~~cdcafgbbieedceeihhklkgigkiolgihnmlmqlrmqtotnossqvsxqtvwuxvuuttwxw}|x~xw~}y}|{{|z}~~|}~dcehcefeciegjhihiljhjokihoinnnijnntrntspnsrsuqqpttxyxtswxuv{w{|x|xz~}{y~zz}|||~cbehfdcdjdhikelhnjnjlhkpjpqolppqsrpqorrsotoqppstuxtuvwuvxzu{uv{x|{|}z|}|{}~ijfhihehfljkfihljpkmqqllqpqkqnontosoopptuwvrrusxyvwxw|xzw|xww{xz~z~{|}~~lejihefkllfnhiijqrrpnmpmmslomrqppqpussusxxxuwtsrvy{yxxx|z~x~z~{|}|jhlljihkljmiioikqmnmrqrmqnsmuqpostrrrwtxvtyzu{xt{}zyw~}y~y}|}~hgnimnmiiikmnjjprsqqrptsunrunovsvszwyxwssv{{|{yv}x{|||y~y~}~opkiiljpnpqkonnkqtoqopqqpvtqrrvvvusvxtyvy{xxy}|y{|}{}~{z}||~mmmlkmomnpklqoqqvsqrwuswptrwuvsy{vyvvw}y}y{z}{y}}~}~~~}omokrmplrpromrmnwurvwtuuutwzvxxszz}xy}}|}{~x|z|z~}~~~~mssonsorotrnnrtpquuvwyysszvy{zuuz~y|~z|y~}|~}tssnrqnrsptpuupptyrxuxyzyzx{|{wvy}yz}{{}}~|{{{ttrspupsvqtvxpuxwuxxwwy|xzz|vwwz}~|}~~|||}sptvuswwwtsquwuwxuv||z|x|w|{yzx}{}}~~wurwwwvurtuysvzv|vw~z|z~|{zzy}~~}~~~wvuvuvxvwvswxxvu{}~}x{~~|~{}~qupstsrxuwyuvwyu}zz~wxwzxw{}|x~{}urtvtuxywuty{ytx}~xz{~{~}{|z~|z~yszuxytvzuxxzw}z~{{~{~|{z{{|~{|~¿yv{wx|yvzyzxy}w|~}z~z|}}}~~|¿þww{vy{{{xy{{zw}x|~}v{|zzz{~yxzyz{}}~~¡~y}{{~|{||{}þ||}||}~{{~|~¾ĽŦ}z|{|}|~þ¿ģ~}||}}}ž¿ũ}~ɥDž~ÿĤǧʃľľȬɁſƿŬ΄ĿèǭуȧͬΩſ¨ͭδ¢¥ˮδ½½ɫϱѵþþƩвֳſƧˮͱ׵ſ¿ĤǯгԹá¿ƿĦˬҰշ¾ľĤȫɬҵڼ¾¾áƪϳҲ־ýýĿ¿ƨȩϲѴ׾¾ίѶټ޺½¬ɲζڵݿ½Ľãʦɰα۹¾ÿĿ³ÿþľħɫαӵ׼²¿ƦȪѲҸܼ¾ŵçɪ̳׹ݻ¾½þĜŪȫҲӹ޾¿嚟¾½Ť̴ͫҶٻ¼ý瞜½ȪȪΰڸý堛¿¤Ȭʰеսݼ¾ľÿ鞡ý½ŧ˲Ժܽſſ韡¿éȫ̯Ҵ޽¿¾ꠠľǭ̵غۼ¼롣̴ͭѻý¿¾頦¿ʰ͸־¾ÿ餦ĭǭвֹپ룧ÿū˳̸ľ몥žƬȰ̷Ҿ¿ꫦıγηؿþ멭ïͲ̽¿þ¿뭯´Ƴκ묩ƿȰʶͺſĽľïǷͽվžľijķ̺¾İǶϻýܽľþÿűȹþžܿĿľÿɷ˿Ŀþÿ½ÿ¹Ϳ߿¾ſ¼¿½¾¿ƷüĽ¾½˼þ¿ÿ὾ſƾĿÿʿÿĿƻÿý徾þľýÿÿĿ¿ſÿÿƿÿÿ \ No newline at end of file diff --git a/clients/apple/Tests/PunktfunkKitTests/Stage444Tests.swift b/clients/apple/Tests/PunktfunkKitTests/Stage444Tests.swift index b13fc09a..93c3402d 100644 --- a/clients/apple/Tests/PunktfunkKitTests/Stage444Tests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/Stage444Tests.swift @@ -47,18 +47,21 @@ final class Stage444Tests: XCTestCase { box.lock.lock(); let frame = box.frame; let error = box.error; box.lock.unlock() XCTAssertNil(error.map { "decode error \($0)" }) let ready = try XCTUnwrap(frame, "a 4:4:4 ReadyFrame must be delivered") - XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), 256) - XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), 256) - let pf = CVPixelBufferGetPixelFormatType(ready.pixelBuffer) + guard case .video(let buffer, let isHDR) = ready.image else { + return XCTFail("a VideoToolbox decode must deliver a .video frame") + } + XCTAssertEqual(CVPixelBufferGetWidth(buffer), 256) + XCTAssertEqual(CVPixelBufferGetHeight(buffer), 256) + let pf = CVPixelBufferGetPixelFormatType(buffer) XCTAssertTrue( pf == kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange || pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange, "expected a biplanar 4:4:4 8-bit buffer, got \(fourCCString(pf))") - XCTAssertFalse(ready.isHDR, "an 8-bit BT.709 4:4:4 stream is SDR") + XCTAssertFalse(isHDR, "an 8-bit BT.709 4:4:4 stream is SDR") // The chroma plane (plane 1) must be FULL resolution for 4:4:4 (vs half for 4:2:0) — this is // what lets the unchanged shader sample chroma at the luma UV. - XCTAssertEqual(CVPixelBufferGetWidthOfPlane(ready.pixelBuffer, 1), 256) - XCTAssertEqual(CVPixelBufferGetHeightOfPlane(ready.pixelBuffer, 1), 256) + XCTAssertEqual(CVPixelBufferGetWidthOfPlane(buffer, 1), 256) + XCTAssertEqual(CVPixelBufferGetHeightOfPlane(buffer, 1), 256) } private func fourCCString(_ t: OSType) -> String { diff --git a/clients/apple/Tests/PunktfunkKitTests/VideoToolboxRoundTripTests.swift b/clients/apple/Tests/PunktfunkKitTests/VideoToolboxRoundTripTests.swift index 3b3a15c4..b6dee5cf 100644 --- a/clients/apple/Tests/PunktfunkKitTests/VideoToolboxRoundTripTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/VideoToolboxRoundTripTests.swift @@ -99,8 +99,9 @@ final class VideoToolboxRoundTripTests: XCTestCase { box.lock.unlock() XCTAssertNil(error.map { "decode error \($0)" }) let ready = try XCTUnwrap(frame, "the async output callback must deliver a ReadyFrame") - XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), width) - XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), height) + let buffer = try XCTUnwrap(ready.pixelBuffer, "a VT decode delivers a .video frame") + XCTAssertEqual(CVPixelBufferGetWidth(buffer), width) + XCTAssertEqual(CVPixelBufferGetHeight(buffer), height) XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder") XCTAssertEqual( ready.receivedNs, 41_000_000, "receivedNs round-trips through the frame refcon") diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 1fef7322..a1343825 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -2230,6 +2230,33 @@ pub unsafe extern "C" fn punktfunk_connection_codec( }) } +/// Read the session's negotiated wire shard payload (the `Welcome`'s value, bytes). This is the +/// parse-window size of a [`USER_FLAG_CHUNK_ALIGNED`] AU (PyroWave datagram-aligned mode, +/// design/pyrowave-codec-plan.md §4.4): every `shard_payload`-sized window of the frame buffer +/// starts a fresh self-delimiting chunk. Clients that decode PyroWave natively (the Apple Metal +/// port) need it to walk those AUs; other codecs never need this. +/// +/// # Safety +/// `c` is a valid connection handle; `out` is NULL or writable for one `u32`. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_shard_payload( + c: *mut PunktfunkConnection, + out: *mut u32, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if !out.is_null() { + // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u32`. + unsafe { *out = u32::from(c.inner.shard_payload) }; + } + PunktfunkStatus::Ok + }) +} + /// Send one input event to the host as a QUIC datagram (non-blocking enqueue). /// /// # Safety diff --git a/crates/punktfunk-host/src/encode/linux/pyrowave.rs b/crates/punktfunk-host/src/encode/linux/pyrowave.rs index 67749b12..6c620764 100644 --- a/crates/punktfunk-host/src/encode/linux/pyrowave.rs +++ b/crates/punktfunk-host/src/encode/linux/pyrowave.rs @@ -1130,10 +1130,10 @@ mod tests { ) } - /// Decode an AU with a standalone pyrowave CPU decoder and return plane means (Y, Cb, Cr). - /// This is the Phase-1 "golden frames" oracle: the host-encoded bitstream must round-trip - /// through upstream's own decoder to the CSC's expected values. - unsafe fn decode_plane_means(w: u32, h: u32, au: &[u8]) -> (f64, f64, f64) { + /// Decode an AU with a standalone pyrowave decoder and return the full YUV420P planes. + /// This is the golden oracle for both the Phase-1 smoke check (plane means) and the Apple + /// Metal port's committed PSNR fixtures (`pyrowave_dump_golden`). + unsafe fn decode_planes(w: u32, h: u32, au: &[u8]) -> (Vec, Vec, Vec) { let mut dev: pw::pyrowave_device = std::ptr::null_mut(); assert_eq!( pw::pyrowave_create_default_device(&mut dev), @@ -1177,7 +1177,13 @@ mod tests { ); pw::pyrowave_decoder_destroy(dec); pw::pyrowave_device_destroy(dev); + (y, cb, cr) + } + /// Plane means of an upstream-decoded AU — the Phase-1 smoke assertion. + unsafe fn decode_plane_means(w: u32, h: u32, au: &[u8]) -> (f64, f64, f64) { + // SAFETY: forwarded — same contract as the caller. + let (y, cb, cr) = unsafe { decode_planes(w, h, au) }; let mean = |v: &[u8]| v.iter().map(|&x| x as f64).sum::() / v.len() as f64; (mean(&y), mean(&cb), mean(&cr)) } @@ -1304,4 +1310,107 @@ mod tests { .expect("submit after reset"); assert!(enc.poll().expect("poll").is_some()); } + + /// A deterministic busy BGRA test card (gradients + checker + LCG noise) — flat fills + /// exercise almost none of the entropy decoder, this hits every subband. + fn test_card(w: u32, h: u32, seed: u32) -> CapturedFrame { + let mut rng = seed | 1; + let mut buf = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + rng = rng.wrapping_mul(1664525).wrapping_add(1013904223); + let i = ((y * w + x) * 4) as usize; + let checker = if (x / 16 + y / 16) % 2 == 0 { 48 } else { 0 }; + let noise = (rng >> 24) as u8 / 8; + buf[i] = ((x * 255 / w) as u8).saturating_add(noise); // B + buf[i + 1] = ((y * 255 / h) as u8).saturating_add(checker); // G + buf[i + 2] = (((x + y) * 255 / (w + h)) as u8).saturating_add(noise); // R + buf[i + 3] = 255; + } + } + CapturedFrame { + width: w, + height: h, + pts_ns: seed as u64 * 16_666_667, + format: PixelFormat::Bgrx, + payload: FramePayload::Cpu(buf), + } + } + + /// Dump the Apple Metal port's golden fixtures (plan §4.7): host-encoded AUs (dense AND + /// chunk-aligned) plus upstream's own decode of each as raw YUV420P planes. The Swift test + /// (PyroWaveGoldenTests.swift) PSNR-matches the Metal decode against these — float wavelet + /// math is not bit-exact across implementations, upstream itself ships precision variants. + /// `#[ignore]`d GPU test; regenerate on a Vulkan 1.3 host: + /// cargo test -p punktfunk-host --features pyrowave --no-run + /// PYROWAVE_GOLDEN_DIR=/tmp/golden --ignored --nocapture pyrowave_dump_golden + /// then copy the files into clients/apple/Tests/PunktfunkKitTests/PyroWaveFixtures/. + #[test] + #[ignore = "fixture generator — needs a real Vulkan 1.3 compute device"] + fn pyrowave_dump_golden() { + let dir = match std::env::var("PYROWAVE_GOLDEN_DIR") { + Ok(d) => std::path::PathBuf::from(d), + Err(_) => { + eprintln!("PYROWAVE_GOLDEN_DIR not set — skipping dump"); + return; + } + }; + std::fs::create_dir_all(&dir).expect("create golden dir"); + + // Odd-block geometry on purpose: 256 aligns clean, 144 → aligned 160 exercises the + // block-grid overhang. ~1.6 bpp at 60 fps. + let (w, h) = (256u32, 144u32); + let mut enc = PyroWaveEncoder::open(w, h, 60, 4_000_000).expect("open"); + + let dump = |name: &str, bytes: &[u8]| { + std::fs::write(dir.join(name), bytes).expect("write fixture"); + eprintln!("wrote {name}: {} bytes", bytes.len()); + }; + + // Dense AU + upstream-decoded reference planes. + enc.submit(&test_card(w, h, 7)).expect("submit"); + let au = enc.poll().expect("poll").expect("AU"); + assert!(!au.chunk_aligned); + dump("au-dense.bin", &au.data); + // SAFETY: test-only FFI with locally-owned buffers. + let (y, cb, cr) = unsafe { decode_planes(w, h, &au.data) }; + dump("ref-dense-y.bin", &y); + dump("ref-dense-cb.bin", &cb); + dump("ref-dense-cr.bin", &cr); + + // Chunk-aligned AU of a DIFFERENT frame (its own reference): the Swift window walk + + // FRAG reassembly must reproduce the packet stream. + enc.set_wire_chunking(1408); + enc.submit(&test_card(w, h, 11)).expect("chunked submit"); + let au = enc.poll().expect("poll").expect("chunked AU"); + assert!(au.chunk_aligned); + assert_eq!(au.data.len() % 1408, 0); + dump("au-chunked.bin", &au.data); + // SAFETY: test-only FFI with locally-owned buffers. + let (y, cb, cr) = unsafe { + // Feed upstream through the same framed walk the clients use. + let mut stream = Vec::new(); + let mut frag: Vec = Vec::new(); + for win in au.data.chunks(1408) { + let used = u16::from_le_bytes([win[0], win[1]]) as usize; + let kind = u16::from_le_bytes([win[2], win[3]]); + let body = &win[4..4 + used]; + match kind { + 0 => stream.extend_from_slice(body), + 1 => frag = body.to_vec(), + 2 => frag.extend_from_slice(body), + 3 => { + frag.extend_from_slice(body); + stream.extend_from_slice(&frag); + frag.clear(); + } + k => panic!("unknown window kind {k}"), + } + } + decode_planes(w, h, &stream) + }; + dump("ref-chunked-y.bin", &y); + dump("ref-chunked-cb.bin", &cb); + dump("ref-chunked-cr.bin", &cr); + } } diff --git a/docs-site/content/docs/pyrowave.md b/docs-site/content/docs/pyrowave.md index 72651d47..15a38392 100644 --- a/docs-site/content/docs/pyrowave.md +++ b/docs-site/content/docs/pyrowave.md @@ -47,9 +47,14 @@ setting. 1. **Host** (Linux): build/install a host with the `pyrowave` feature. On an NVIDIA host the capture path additionally needs `PUNKTFUNK_ENCODER=pyrowave` in `host.env` for the codec to be advertised; AMD/Intel hosts advertise it automatically when the feature is present. -2. **Client** (Linux session client with the `pyrowave` feature): set **Settings → Video - codec → PyroWave (wired LAN)** in the gamepad console, or launch with - `PUNKTFUNK_PREFER_PYROWAVE=1`. +2. **Client**: + - Linux session client (with the `pyrowave` feature): set **Settings → Video codec → + PyroWave (wired LAN)** in the gamepad console, or launch with + `PUNKTFUNK_PREFER_PYROWAVE=1`. + - Apple (Mac, Apple TV 4K, iPad — wired networking strongly recommended): set + **Settings → Codec → PyroWave (wired LAN)**. The option appears only on devices whose + GPU passes the decode probe (Apple Silicon and A13-class or newer); picking it forces + the session SDR. 3. Leave the bitrate on Automatic: a PyroWave session pins itself to the ~1.6 bpp rate for your mode (≈200 Mbps at 1080p60). An explicit bitrate is honored if you set one, but the adaptive-bitrate controller stays off either way — this codec has no useful low-rate @@ -61,6 +66,7 @@ The stats overlay shows `pyrowave` as the decode path when the mode is active. ## Current limits -- Linux host + Linux client (including docked Deck) today; the Windows host and the Apple - native port are tracked on the [roadmap](/docs/roadmap). +- Linux host + Linux client (including docked Deck) and Apple clients (native Metal decode + on Mac / Apple TV 4K / iPad) today; the Windows host is tracked on the + [roadmap](/docs/roadmap). - 8-bit SDR, 4:2:0 only. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 147e35aa..91268555 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -1684,6 +1684,18 @@ PunktfunkStatus punktfunk_connection_codec(PunktfunkConnection *c, uint8_t *out); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Read the session's negotiated wire shard payload (the `Welcome`'s value, bytes). This is the +// parse-window size of a [`USER_FLAG_CHUNK_ALIGNED`] AU (PyroWave datagram-aligned mode, +// design/pyrowave-codec-plan.md §4.4): every `shard_payload`-sized window of the frame buffer +// starts a fresh self-delimiting chunk. Clients that decode PyroWave natively (the Apple Metal +// port) need it to walk those AUs; other codecs never need this. +// +// # Safety +// `c` is a valid connection handle; `out` is NULL or writable for one `u32`. +PunktfunkStatus punktfunk_connection_shard_payload(PunktfunkConnection *c, uint32_t *out); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Send one input event to the host as a QUIC datagram (non-blocking enqueue). //