From 33c33c3d573063aa96a7852bc5bfaf9fb0b05319 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:28:35 +0200 Subject: [PATCH] feat(player): pack TrueHD into a MAT/IEC 61937 carrier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for bitstreaming TrueHD the way other players do (#1804). Android will not bitstream raw TrueHD on the TV routes measured so far. Both connected Android TV boxes report ENCODING_DOLBY_TRUEHD as offload-only while reporting ENCODING_IEC61937 at 192kHz/7.1 as bitstream-capable, and the reporter's box takes a raw TrueHD AudioTrack and then never advances its playback head. Kodi models this split explicitly: it offers an "AudioTrack (IEC)" sink where it packs the carrier itself and treats handing raw TrueHD to Android as the fallback, and even that fallback runs at 192kHz. Media3 only ever does the raw form, at the stream rate. This adds the packer half: split a sample into TrueHD access units, assemble MAT frames with timing-derived padding, and emit IEC 61937 bursts. It is a port of FFmpeg's spdif_header_truehd rather than Kodi's CAEBitstreamPacker, because Kodi's is a thin wrapper over an already-assembled buffer while the MAT code placement and padding live in FFmpeg's stateful packer. Details the port has to get right. Media3's Matroska path concatenates 16 syncframes into one sample, so access units are split here; reading a single input_timing for sixteen frames would desynchronise the carrier. Burst buffers alternate and are reused rather than allocated, because a fresh 61,440 byte array every 20ms is roughly 3MB/s of garbage on the low-power hardware this runs on. A 44.1kHz-family stream rides a 176.4kHz carrier instead of 192kHz, which changes the whole AudioTrack tuple, so it is reported as unsupported for the caller to decode instead. A wrong byte here is not subtle — the receiver drops sync or renders full-scale noise — so the test compares against FFmpeg's own output byte for byte, using its input and output as fixtures. No caller yet; the sink that routes TrueHD through this follows. --- .../plezy/exoplayer/TrueHdMatPacker.kt | 258 ++++++++++++++++++ .../plezy/exoplayer/TrueHdMatPackerTest.kt | 155 +++++++++++ .../test/resources/truehd_access_units.bin | Bin 0 -> 3088 bytes .../test/resources/truehd_iec61937_golden.bin | Bin 0 -> 245760 bytes 4 files changed, 413 insertions(+) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdMatPacker.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdMatPackerTest.kt create mode 100644 android/app/src/test/resources/truehd_access_units.bin create mode 100644 android/app/src/test/resources/truehd_iec61937_golden.bin diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdMatPacker.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdMatPacker.kt new file mode 100644 index 00000000..19d1dcab --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdMatPacker.kt @@ -0,0 +1,258 @@ +package com.edde746.plezy.exoplayer + +import java.nio.ByteBuffer +/** + * Packs Dolby TrueHD access units into MAT frames carried in IEC 61937 bursts (#1804). + * + * Android will not bitstream raw TrueHD on the TV routes we have measured — the platform reports + * `ENCODING_DOLBY_TRUEHD` as offload-only while reporting `ENCODING_IEC61937` at 192kHz/7.1 as + * bitstream-capable. That is also the split Kodi models: it offers an "AudioTrack (IEC)" sink where + * it packs the carrier itself, and treats handing raw TrueHD to Android as the fallback. Media3 only + * ever does the latter, and at the stream rate rather than the carrier rate, which is why playback + * wedges where other players work. + * + * The algorithm is a port of FFmpeg's `spdif_header_truehd` (libavformat/spdifenc.c, n8.0). Kodi's + * `CAEBitstreamPacker` is *not* an equivalent reference: it wraps an already-assembled buffer, + * whereas the timing-driven padding and MAT code placement live in FFmpeg's stateful packer. + * + * Output is a complete IEC 61937 burst per MAT frame: + * + * - 8 byte preamble, little endian: `Pa=0xF872 Pb=0x4E1F Pc=0x0016 Pd=61424` + * - 61424 bytes of MAT frame, 16-bit byte-swapped + * - 8 bytes of zero padding + * - 61440 bytes total, which is 20ms at 192kHz/8ch/16-bit + * + * Not thread safe; the sink drives it from the playback thread only. + */ +internal class TrueHdMatPacker { + + internal companion object { + /** Payload bytes in one MAT frame. */ + const val MAT_FRAME_SIZE = 61424 + + /** Bytes from the start of one burst to the next, including preamble and trailing gap. */ + const val MAT_PKT_OFFSET = 61440 + + /** Carrier the packed stream must be played at. */ + const val CARRIER_SAMPLE_RATE = 192_000 + const val CARRIER_CHANNEL_COUNT = 8 + const val CARRIER_BYTES_PER_FRAME = CARRIER_CHANNEL_COUNT * 2 + + private const val BURST_HEADER_SIZE = 8 + private const val SYNCWORD1 = 0xF872 + private const val SYNCWORD2 = 0x4E1F + private const val IEC61937_TRUEHD = 0x16 + + /** Minimum bytes needed to read an access unit header plus its major-sync probe. */ + private const val MIN_ACCESS_UNIT_LENGTH = 10 + + private val MAT_START_CODE = byteArrayOf( + 0x07, 0x9E.toByte(), 0x00, 0x03, 0x84.toByte(), 0x01, 0x01, 0x01, 0x80.toByte(), 0x00, + 0x56, 0xA5.toByte(), 0x3B, 0xF4.toByte(), 0x81.toByte(), 0x83.toByte(), 0x49, 0x80.toByte(), + 0x77, 0xE0.toByte() + ) + private val MAT_MIDDLE_CODE = byteArrayOf( + 0xC3.toByte(), 0xC1.toByte(), 0x42, 0x49, 0x3B, 0xFA.toByte(), 0x82.toByte(), 0x83.toByte(), + 0x49, 0x80.toByte(), 0x77, 0xE0.toByte() + ) + private val MAT_END_CODE = byteArrayOf( + 0xC3.toByte(), 0xC2.toByte(), 0xC0.toByte(), 0xC4.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x97.toByte(), 0x11 + ) + + private val MAT_CODES = arrayOf(MAT_START_CODE, MAT_MIDDLE_CODE, MAT_END_CODE) + private val MAT_CODE_POSITIONS = intArrayOf(0, 30708, MAT_FRAME_SIZE - MAT_END_CODE.size) + + /** + * Length of the access unit starting at [offset], in bytes. + * + * The first 16-bit big-endian word carries the length in units of 16-bit words in its low 12 + * bits. Returns 0 when the buffer is too short or the length is nonsensical, which the caller + * treats as "stop walking". + */ + fun accessUnitLength(data: ByteArray, offset: Int, limit: Int): Int { + if (offset + 2 > limit) return 0 + val words = ((data[offset].toInt() and 0x0F) shl 8) or (data[offset + 1].toInt() and 0xFF) + val length = words * 2 + return if (length < 4 || offset + length > limit) 0 else length + } + + /** True when the access unit at [offset] carries a major sync, which announces the rate. */ + private fun hasMajorSync(data: ByteArray, offset: Int, limit: Int): Boolean { + if (offset + 8 > limit) return false + return (data[offset + 4].toInt() and 0xFF) == 0xF8 && + (data[offset + 5].toInt() and 0xFF) == 0x72 && + (data[offset + 6].toInt() and 0xFF) == 0x6F + } + } + + private val matBuffers = Array(2) { ByteArray(MAT_FRAME_SIZE) } + private val burstBacking = Array(2) { ByteArray(MAT_PKT_OFFSET) } + private val burstBuffers = Array(2) { + ByteBuffer.wrap(burstBacking[it]).order(java.nio.ByteOrder.LITTLE_ENDIAN) + } + private var burstIndex = 0 + private var matBufferIndex = 0 + private var matBufferFilled = 0 + private var previousTiming = 0 + private var previousSize = 0 + private var samplesPerFrame = 0 + + /** Drops all carrier state. Call on flush/seek: MAT frames must not straddle a discontinuity. */ + fun reset() { + matBufferIndex = 0 + matBufferFilled = 0 + previousTiming = 0 + previousSize = 0 + samplesPerFrame = 0 + java.util.Arrays.fill(matBuffers[0], 0) + java.util.Arrays.fill(matBuffers[1], 0) + } + + /** + * True when the stream announced a 44.1kHz-family rate. + * + * That family rides a 176.4kHz carrier rather than 192kHz, which changes the AudioTrack tuple the + * whole path is built around. Rather than carry a second carrier configuration for a combination + * that is essentially absent from real media, the sink reads this and falls back to decoding. + */ + var unsupportedRateFamily: Boolean = false + private set + + /** + * Packs one access unit, returning a completed burst when this unit finished a MAT frame. + * + * The returned buffer is owned by the packer and reused, alternating between two so a burst + * handed downstream stays valid while the next frame fills. Callers must submit or copy it before + * the second following call. This is the audio path on low-power TV hardware — a fresh 61,440 + * byte array every 20ms would be roughly 3MB/s of garbage. + * + * Faithful to `spdif_header_truehd`: padding between units is derived from the delta of their + * `input_timing` fields against the previous unit's on-carrier size, so the carrier keeps a fixed + * rate even though the source frames vary in length. + * + * At most one burst can complete per access unit: padding is bounded below half a MAT frame and + * an access unit is far smaller, so a single unit cannot span two frame boundaries. + */ + fun packAccessUnit(data: ByteArray, offset: Int, length: Int): ByteBuffer? { + if (length < MIN_ACCESS_UNIT_LENGTH) return null + + if (hasMajorSync(data, offset, offset + length)) { + // Rate is announced in the major sync; 40 samples per frame at 48kHz, doubling with the rate. + val rateBits = when (data[offset + 7].toInt() and 0xFF) { + 0xBA -> (data[offset + 8].toInt() and 0xFF) shr 4 + 0xBB -> (data[offset + 9].toInt() and 0xFF) shr 4 + else -> return null + } + // Bit 3 selects the 44.1kHz family, which rides a 176.4kHz carrier we do not build. + unsupportedRateFamily = (rateBits and 8) != 0 + if (unsupportedRateFamily) return null + samplesPerFrame = 40 shl (rateBits and 3) + } + if (unsupportedRateFamily || samplesPerFrame == 0) return null + + val inputTiming = ((data[offset + 2].toInt() and 0xFF) shl 8) or (data[offset + 3].toInt() and 0xFF) + var paddingRemaining = 0 + if (previousSize != 0) { + val deltaSamples = (inputTiming - previousTiming) and 0xFFFF + // One 48kHz-family frame is 1/1200s; the carrier runs at 768000*4 bytes/s, so the nominal + // space per frame is 2560 bytes, which divides evenly by samplesPerFrame. + val deltaBytes = deltaSamples * 2560 / samplesPerFrame + paddingRemaining = deltaBytes - previousSize + if (paddingRemaining < 0 || paddingRemaining >= MAT_FRAME_SIZE / 2) { + // Timing we do not model; better a momentary rate wobble than a corrupt frame. + paddingRemaining = 0 + } + } + + var totalFrameSize = length + var dataCursor = offset + var dataRemaining = length + var completed: ByteBuffer? = null + + var nextCodeIndex = MAT_CODE_POSITIONS.indexOfFirst { matBufferFilled <= it } + if (nextCodeIndex < 0) return null + + while (paddingRemaining > 0 || dataRemaining > 0 || MAT_CODE_POSITIONS[nextCodeIndex] == matBufferFilled) { + if (MAT_CODE_POSITIONS[nextCodeIndex] == matBufferFilled) { + val code = MAT_CODES[nextCodeIndex] + var codeLengthRemaining = code.size + code.copyInto(matBuffers[matBufferIndex], matBufferFilled) + matBufferFilled += code.size + nextCodeIndex++ + + if (nextCodeIndex == MAT_CODES.size) { + nextCodeIndex = 0 + // Last code of the frame: this buffer is complete, continue filling the other one. + completed = toIec61937Burst(matBuffers[matBufferIndex]) + matBufferIndex = matBufferIndex xor 1 + matBufferFilled = 0 + // The inter-frame gap occupies carrier space too. + codeLengthRemaining += MAT_PKT_OFFSET - MAT_FRAME_SIZE + } + + if (paddingRemaining > 0) { + val countedAsPadding = minOf(paddingRemaining, codeLengthRemaining) + paddingRemaining -= countedAsPadding + codeLengthRemaining -= countedAsPadding + } + if (codeLengthRemaining > 0) totalFrameSize += codeLengthRemaining + } + + if (paddingRemaining > 0) { + val padding = minOf(MAT_CODE_POSITIONS[nextCodeIndex] - matBufferFilled, paddingRemaining) + java.util.Arrays.fill(matBuffers[matBufferIndex], matBufferFilled, matBufferFilled + padding, 0) + matBufferFilled += padding + paddingRemaining -= padding + if (paddingRemaining > 0) continue + } + + if (dataRemaining > 0) { + val toCopy = minOf(MAT_CODE_POSITIONS[nextCodeIndex] - matBufferFilled, dataRemaining) + data.copyInto(matBuffers[matBufferIndex], matBufferFilled, dataCursor, dataCursor + toCopy) + matBufferFilled += toCopy + dataCursor += toCopy + dataRemaining -= toCopy + } + } + + previousSize = totalFrameSize + previousTiming = inputTiming + return completed + } + + /** + * Wraps a finished MAT frame in its IEC 61937 burst, into the next reusable buffer. + * + * Alternating between two keeps a burst already handed downstream intact while the following + * frame is assembled. + */ + private fun toIec61937Burst(matFrame: ByteArray): ByteBuffer { + burstIndex = burstIndex xor 1 + val burst = burstBuffers[burstIndex] + val backing = burstBacking[burstIndex] + putLittleEndianShort(backing, 0, SYNCWORD1) + putLittleEndianShort(backing, 2, SYNCWORD2) + putLittleEndianShort(backing, 4, IEC61937_TRUEHD) + putLittleEndianShort(backing, 6, MAT_FRAME_SIZE) + // The carrier is a 16-bit sample stream, so the payload goes out byte-swapped per word. + var source = 0 + var destination = BURST_HEADER_SIZE + while (source < MAT_FRAME_SIZE) { + backing[destination] = matFrame[source + 1] + backing[destination + 1] = matFrame[source] + source += 2 + destination += 2 + } + // Trailing bytes stay zero for the inter-frame gap; the backing array is never re-dirtied + // beyond MAT_FRAME_SIZE, so they remain zero for the life of the packer. + burst.limit(MAT_PKT_OFFSET) + burst.position(0) + return burst + } + + private fun putLittleEndianShort(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value and 0xFF).toByte() + target[offset + 1] = ((value shr 8) and 0xFF).toByte() + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdMatPackerTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdMatPackerTest.kt new file mode 100644 index 00000000..d208019e --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdMatPackerTest.kt @@ -0,0 +1,155 @@ +package com.edde746.plezy.exoplayer + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the TrueHD -> MAT/IEC 61937 carrier against FFmpeg (#1804). + * + * The carrier is fed straight to an `ENCODING_IEC61937` AudioTrack, so a wrong byte is not a subtle + * defect: the receiver either drops sync or renders the carrier as full-scale noise. The only + * defensible bar is byte-for-byte agreement with a reference implementation, so the fixtures are + * FFmpeg's own input and output: + * + * ``` + * ffmpeg -i surround_5_1_truehd.mka -c:a copy -f truehd truehd_access_units.bin + * ffmpeg -i surround_5_1_truehd.mka -c:a copy -f spdif truehd_iec61937_golden.bin + * ``` + */ +class TrueHdMatPackerTest { + + private fun resource(name: String): ByteArray = + checkNotNull(javaClass.classLoader?.getResourceAsStream(name)) { "missing fixture $name" } + .use { it.readBytes() } + + private val accessUnits by lazy { resource("truehd_access_units.bin") } + private val golden by lazy { resource("truehd_iec61937_golden.bin") } + + /** The whole point: our carrier and FFmpeg's are the same bytes. */ + @Test + fun packedCarrierMatchesFfmpegByteForByte() { + val packed = packAll(accessUnits) + + assertEquals( + "burst count differs from FFmpeg", + golden.size / TrueHdMatPacker.MAT_PKT_OFFSET, + packed.size / TrueHdMatPacker.MAT_PKT_OFFSET + ) + // Compare per burst so a failure names the frame rather than dumping 240KB. + for (index in 0 until packed.size / TrueHdMatPacker.MAT_PKT_OFFSET) { + val from = index * TrueHdMatPacker.MAT_PKT_OFFSET + val to = from + TrueHdMatPacker.MAT_PKT_OFFSET + assertArrayEquals( + "burst $index differs from FFmpeg", + golden.copyOfRange(from, to), + packed.copyOfRange(from, to) + ) + } + } + + /** Each burst is a full IEC 61937 frame: preamble, then payload, then the inter-frame gap. */ + @Test + fun everyBurstCarriesTheTrueHdPreamble() { + val packed = packAll(accessUnits) + assertTrue("expected at least one burst", packed.isNotEmpty()) + + for (index in 0 until packed.size / TrueHdMatPacker.MAT_PKT_OFFSET) { + val at = index * TrueHdMatPacker.MAT_PKT_OFFSET + assertEquals("burst $index Pa", 0xF872, readLittleEndianShort(packed, at)) + assertEquals("burst $index Pb", 0x4E1F, readLittleEndianShort(packed, at + 2)) + assertEquals("burst $index Pc (IEC61937_TRUEHD)", 0x16, readLittleEndianShort(packed, at + 4)) + assertEquals("burst $index Pd", TrueHdMatPacker.MAT_FRAME_SIZE, readLittleEndianShort(packed, at + 6)) + } + } + + /** A burst is exactly 20ms of carrier, which is what makes the PCM-domain accounting downstream correct. */ + @Test + fun aBurstIsTwentyMillisecondsOfCarrier() { + val framesPerBurst = TrueHdMatPacker.MAT_PKT_OFFSET / TrueHdMatPacker.CARRIER_BYTES_PER_FRAME + val durationUs = framesPerBurst * 1_000_000L / TrueHdMatPacker.CARRIER_SAMPLE_RATE + assertEquals(20_000L, durationUs) + } + + /** + * Media3's Matroska path concatenates 16 syncframes into one sample, so the packer has to split + * them itself; feeding a whole sample as if it were one access unit reads a single timing for + * sixteen frames and desynchronises the carrier. + */ + @Test + fun aRechunkedSampleIsSplitIntoItsAccessUnits() { + var offset = 0 + var units = 0 + while (offset < accessUnits.size) { + val length = TrueHdMatPacker.accessUnitLength(accessUnits, offset, accessUnits.size) + if (length == 0) break + units++ + offset += length + } + assertTrue("fixture should contain many access units, found $units", units > 16) + assertEquals("access unit walk must consume the stream exactly", accessUnits.size, offset) + } + + /** Reset must not leave a half-filled frame behind, or the next seek emits a spliced burst. */ + @Test + fun resetDropsPartialCarrierState() { + val packer = TrueHdMatPacker() + feed(packer, accessUnits, stopAfterBursts = 1) + packer.reset() + + val afterReset = packAll(accessUnits, packer) + assertArrayEquals( + "a reset packer must reproduce the stream from the start", + packAll(accessUnits), + afterReset + ) + } + + /** The packer must not allocate a burst per frame; buffers alternate and are reused. */ + @Test + fun burstBuffersAreReusedRatherThanAllocated() { + val packer = TrueHdMatPacker() + val seen = java.util.IdentityHashMap() + var bursts = 0 + var offset = 0 + while (offset < accessUnits.size) { + val length = TrueHdMatPacker.accessUnitLength(accessUnits, offset, accessUnits.size) + if (length == 0) break + packer.packAccessUnit(accessUnits, offset, length)?.let { seen[it] = true; bursts++ } + offset += length + } + assertTrue("expected several bursts, saw $bursts", bursts > 2) + assertTrue("at most two buffers may ever be handed out, saw ${seen.size}", seen.size <= 2) + } + + private fun packAll(units: ByteArray, packer: TrueHdMatPacker = TrueHdMatPacker()): ByteArray { + val out = java.io.ByteArrayOutputStream() + var offset = 0 + while (offset < units.size) { + val length = TrueHdMatPacker.accessUnitLength(units, offset, units.size) + if (length == 0) break + packer.packAccessUnit(units, offset, length)?.let { burst -> + val copy = ByteArray(burst.remaining()) + burst.duplicate().get(copy) + out.write(copy) + } + offset += length + } + return out.toByteArray() + } + + private fun feed(packer: TrueHdMatPacker, units: ByteArray, stopAfterBursts: Int) { + var offset = 0 + var bursts = 0 + while (offset < units.size && bursts < stopAfterBursts) { + val length = TrueHdMatPacker.accessUnitLength(units, offset, units.size) + if (length == 0) break + if (packer.packAccessUnit(units, offset, length) != null) bursts++ + offset += length + } + } + + private fun readLittleEndianShort(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) +} diff --git a/android/app/src/test/resources/truehd_access_units.bin b/android/app/src/test/resources/truehd_access_units.bin new file mode 100644 index 0000000000000000000000000000000000000000..d492d967876017a3c566e23a6579ee27e5fae2b1 GIT binary patch literal 3088 zcma*pKZqk$90%|>f0FrTGLyVnL4&w3h{Bem)v{di5Wy{?42yzgSV0yW!=bR)++ZPF z$T3!KIh;+(>0B%%Wh#lFaERh7MArdfQV0RT^X;u8-wO#&AQ?X7`y;&f;|p1iA3a>( zeg7-rWy3E|gn4~>FTQj0ym|fdZTw7pw5o)#q@e|6KArbIb~DR~h04SW>ZR{Ku!ZOz z-OtQ-nK=FCCjDaiU)q+Z{=3b0FSZQqE5J>wH-*h{v8y=DtIF;%pw+vMkL1?T!nyOOQp9mJ=$x}n zzF-S<7HpHR*&5vuJ3&{mb9B$xCZDqvx>szI*K7?B>=a(Yj>le$j(FS=`^V;jw`}C( zxmD$Dbi{wU8ECta-?Rw59UW2dW_*GY-jXfht=T0Ivs2)ht$`^!11{MnKWEp#H9JR7 zvJ>V$D zfrg*575sCy$rtPz{(^1tH9J9n#MbC5wnG1mZSpxgNB@d#@|v9@09zwS*!ehU(UE{V z;(Xm)@Rp4n{kp1x&5k^52HI}q`Jc)#*wK+1!HiEJ5G>gRf;C$sjM)O=F}p-KWiJt4 zvQ2)@E)ZU`H3pKcFgRrA7);nT1{Z9TFWJd>(4r$l?udP-x!`RZ`FvFkH#*{f(hRiS z$fKV{INZ?@h2a68pu{j`OAOE1CSR}%3>R#Zuh}`|h^-(MTSK0)O+IHQkXLMz*X$e- z*fFAnosOdx9f@u`GJ5>)*Z!!v;4K^Z>yK3x-FD>Pv^?$q-3+wdi1pMojCOP+K{VqN Y%n>cw6GUsaz+TMO*gIyY*qgHd0iQYtEdT%j literal 0 HcmV?d00001 diff --git a/android/app/src/test/resources/truehd_iec61937_golden.bin b/android/app/src/test/resources/truehd_iec61937_golden.bin new file mode 100644 index 0000000000000000000000000000000000000000..10853c02e1c635ce7379235682b86ac34b2f9e77 GIT binary patch literal 245760 zcmeI*Pe>bK8prWx{?t5LU8k2?mJJ;ig&x&Yi5^~xU<+;u;+7tR!cz8P5zF@G+t@=7 z67Bq?&I5X_U(Np5bw3;iT&{8``Gd;`zZO#)WO&PEmU{rGtzITwtGnxpo|$ z48*y(axM~iBAyr(38eUE8wQ@l)(pysSv(TIOG-Io(+lk1W^1eSL-p0Z%tn9e35%YM z10%9O8igWhl?gu^m`RD} z2@Bl?vldzLk1H2vH8bnKSeVNZKmY**5I_I{1lm$Sx9*8w2n%Ae|M~gXmb+wA2q1s} z0tg_000LJfpe9fLyDCiTAbMexLkz4d%oa5kLR|1Q0*~fvXbG%y2q1s}0tg_0z;zJNF}7T+{noDze!8>d z0tg_000Iag&;$WZ67pXYTq%hF0tg_000IcKtAK8)&-1_SIvw_f00IagfB*srT)lwG zF!}H5S*eHs0tg_000IcKt-#dtr~X29XFenSc6z(E&83y`xW3)PlYuxFSI(^;c_N+| z6$zwRwPD~%Y|WsIn8hRUyQGveHoeN)h1%-;P<^#8vr+kGSoCZh7?J(aC=^MnO!(Qr zOiDZ_r<)S?O7?|K>u;2za#|(+`ybO+4g?TD009ILKmdUz2=wKx9~Qa`W-YSfA6G8U zYG&4((2SA@Ab#Y1XSXH?|*5XH=Phb009ILKmY;P0-8J||G6Fl z{SiO_0R#|00D%??X#P3*uZ7;ULI42-5I_I{1ez`IdGE{aWsh5L>+ct;cOT!Dvv2P+ zfq1VyPwa;$-^Z3;UHm5SJ2GJ%iZcQTAbjV6Abcu3vC?G7#tD%DLQ#JP}Wfiuh7| zY{S5l*qT8ZF^fmycS$K{Y**^80tg_000IagfIt%j`pR-6EOZylT4cpPu3Vhe%#gsE zpiM~x5I_I{1Q0;Ly?`e7Zt(pt?&rV`5I_I{1Q0*~fmR73MeK$9~0&jma^5kLR|1Q0*~0Y?HV z`{X}I_OwL+0R#|0009JC2X0tg_000Iag;7UMOpOODu;nNoZ1Q0*~0R#|mAfU-A`OkqnEfGKf z0R#|000CD5YCe+xT;bCf0R#|0009ILa3G+o3Hi@~J1r4F009ILKmY+(0-9)s=YL)4 z(-#2*5I_I{1Q2MwfaZtEf30VyBLWB@fB*srAkZ{{&wF2XFN@!LTYtY$z5DpKoPB$r z3B-Htd1602`98M%s`i_}Q}NTxLUm_8BmH)IyLQFW%6MGA^6+FJ&c&5;n~6LTPmGHA zQv8Pv15aXW24%!79*N&2rJS+pRrYt&wbl8d%hj{d?Vhmc**GvF`=e1Pl2)1Uvw@kE zcur0?CG3^#3!B#8C`IM8N}T*VedRy^0R#|0009IL_(g#}v6-;YT`+5r75}($aaJ?4 z{)>gV903FnKmY**5I~?U1=MWv|KGNp44Xm#0R#|0009J=BcNLc{V&b2r78jlAb)Vw7BxgP^NKmY**5I_I{ z1X?DbQJMVLGHn_mfB*srAb34D0{_1Q0*~0R#|enSh!z@?XoeX@md* z2q1s}0thr;;Pc*>-ODbw-qznQRPR2%Eoa}}X9DqFd!E=2Pri>WzqYCb%xe z836S(n|2k`=+zkQu;2za#|(+=kL>3 z4g?TD009ILKmdUz2=r~)-muVJFl&(&|G08-Rx`8Sgl3dP009ILKmY**5O6M_XPf;0 z*ZCCK00IagfB*srAkaDil>_o$>$vHJ00IagfB*srI1%`?_vpcovE|~=-})_A$$w6! zKvM(|KmY**5J13