From 4d3a781ca4d905f84fb9d6c60a27315ba0561209 Mon Sep 17 00:00:00 2001 From: claincly Date: Wed, 30 Sep 2020 19:59:39 +0100 Subject: [PATCH] End to end playback test for gapless playback In the test, a real instance of SimpleExoplayer plays two identical Mp3 files. The GaplessMp3Decoder will write randomized data to decoder output on receiving input. The test compares the bytes written by the decoder with the bytes received by the AudioTrack, to verify that the trimming of encoder delay/ padding is correctly carried out. Test mp3 has delay 576 frames and padding 1404 frames. File generated from: ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.mp3 This change needs robolectric version 4.5, which is not currently released (2020 Sep 30). PiperOrigin-RevId: 334648486 --- constants.gradle | 2 +- .../e2etest/EndToEndGaplessTest.java | 150 ++++++++++++++++++ .../robolectric/RandomizedMp3Decoder.java | 94 +++++++++++ testdata/src/test/assets/media/mp3/test.mp3 | Bin 0 -> 8586 bytes 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/e2etest/EndToEndGaplessTest.java create mode 100644 robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/RandomizedMp3Decoder.java create mode 100644 testdata/src/test/assets/media/mp3/test.mp3 diff --git a/constants.gradle b/constants.gradle index c2b0000368..82a6a55479 100644 --- a/constants.gradle +++ b/constants.gradle @@ -24,7 +24,7 @@ project.ext { guavaVersion = '27.1-android' mockitoVersion = '2.28.2' mockWebServerVersion = '3.12.0' - robolectricVersion = '4.4' + robolectricVersion = '4.5-SNAPSHOT' checkerframeworkVersion = '3.3.0' checkerframeworkCompatVersion = '2.5.0' jsr305Version = '3.0.2' diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/EndToEndGaplessTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/EndToEndGaplessTest.java new file mode 100644 index 0000000000..7d953fc8e6 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/EndToEndGaplessTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.e2etest; + +import static com.google.common.truth.Truth.assertThat; +import static java.lang.Integer.max; + +import android.media.AudioFormat; +import android.media.MediaFormat; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.robolectric.RandomizedMp3Decoder; +import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; +import com.google.android.exoplayer2.testutil.TestExoPlayer; +import com.google.android.exoplayer2.util.Assertions; +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Bytes; +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.MediaCodecInfoBuilder; +import org.robolectric.shadows.ShadowAudioTrack; +import org.robolectric.shadows.ShadowMediaCodec; +import org.robolectric.shadows.ShadowMediaCodecList; + +/** End to end playback test for gapless audio playbacks. */ +@RunWith(AndroidJUnit4.class) +@Config(sdk = 29) +public class EndToEndGaplessTest { + private static final int CODEC_INPUT_BUFFER_SIZE = 5120; + private static final int CODEC_OUTPUT_BUFFER_SIZE = 5120; + private static final String DECODER_NAME = "RandomizedMp3Decoder"; + + private RandomizedMp3Decoder mp3Decoder; + private AudioTrackListener audioTrackListener; + + @Before + public void setUp() throws Exception { + audioTrackListener = new AudioTrackListener(); + ShadowAudioTrack.addAudioDataListener(audioTrackListener); + + mp3Decoder = new RandomizedMp3Decoder(); + ShadowMediaCodec.addDecoder( + DECODER_NAME, + new ShadowMediaCodec.CodecConfig( + CODEC_INPUT_BUFFER_SIZE, CODEC_OUTPUT_BUFFER_SIZE, mp3Decoder)); + + MediaFormat mp3Format = new MediaFormat(); + mp3Format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_MPEG); + ShadowMediaCodecList.addCodec( + MediaCodecInfoBuilder.newBuilder() + .setName(DECODER_NAME) + .setCapabilities( + MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder() + .setMediaFormat(mp3Format) + .build()) + .build()); + } + + @Test + public void testPlayback_twoIdenticalMp3Files() throws Exception { + SimpleExoPlayer player = + new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()) + .setClock(new AutoAdvancingFakeClock()) + .build(); + + player.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset:///media/mp3/test.mp3"), + MediaItem.fromUri("asset:///media/mp3/test.mp3"))); + player.prepare(); + player.play(); + TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); + + Format playerAudioFormat = player.getAudioFormat(); + assertThat(playerAudioFormat).isNotNull(); + + int bytesPerFrame = audioTrackListener.getAudioTrackOutputFormat().getFrameSizeInBytes(); + int paddingBytes = max(0, playerAudioFormat.encoderPadding) * bytesPerFrame; + int delayBytes = max(0, playerAudioFormat.encoderDelay) * bytesPerFrame; + assertThat(paddingBytes).isEqualTo(2808); + assertThat(delayBytes).isEqualTo(1152); + + byte[] decoderOutputBytes = Bytes.concat(mp3Decoder.getAllOutputBytes().toArray(new byte[0][])); + int bytesPerAudioFile = decoderOutputBytes.length / 2; + assertThat(bytesPerAudioFile).isEqualTo(92160); + + byte[] expectedTrimmedByteContent = + Bytes.concat( + // Track one is trimmed at its beginning and its end. + Arrays.copyOfRange(decoderOutputBytes, delayBytes, bytesPerAudioFile - paddingBytes), + // Track two is only trimmed at its beginning, but not its end. + Arrays.copyOfRange( + decoderOutputBytes, bytesPerAudioFile + delayBytes, decoderOutputBytes.length)); + + byte[] audioTrackReceivedBytes = audioTrackListener.getAllReceivedBytes(); + assertThat(audioTrackReceivedBytes).isEqualTo(expectedTrimmedByteContent); + } + + private static class AudioTrackListener implements ShadowAudioTrack.OnAudioDataWrittenListener { + private final ByteArrayOutputStream audioTrackReceivedBytesStream = new ByteArrayOutputStream(); + // Output format from the audioTrack. + private AudioFormat format; + private ShadowAudioTrack audioTrack; + + @Override + public synchronized void onAudioDataWritten( + ShadowAudioTrack audioTrack, byte[] audioData, AudioFormat format) { + if (this.audioTrack == null) { + this.audioTrack = audioTrack; + } else { + Assertions.checkArgument( + audioTrack == this.audioTrack, "Data written from a different AudioTrack"); + } + + if (!format.equals(this.format)) { + this.format = format; + } + audioTrackReceivedBytesStream.write(audioData, 0, audioData.length); + } + + public byte[] getAllReceivedBytes() { + return audioTrackReceivedBytesStream.toByteArray(); + } + + public AudioFormat getAudioTrackOutputFormat() { + return format; + } + } +} diff --git a/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/RandomizedMp3Decoder.java b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/RandomizedMp3Decoder.java new file mode 100644 index 0000000000..1b033e1955 --- /dev/null +++ b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/RandomizedMp3Decoder.java @@ -0,0 +1,94 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.robolectric; + +import android.media.AudioFormat; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.view.Surface; +import com.google.android.exoplayer2.audio.MpegAudioUtil; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import org.robolectric.shadows.ShadowMediaCodec; + +/** + * Generates randomized, but correct amount of data on MP3 audio input. + * + *

The decoder reads the MP3 header for each input MP3 frame, determines the number of bytes the + * input frame should inflate to, and writes randomized data of that amount to the output buffer. + * Decoder randomness can help us identify possible errors in downstream renderers and audio + * processors. The random bahavior is deterministic, it outputs the same bytes across multiple runs. + * + *

All the data written to the output by the decoder can be obtained by getAllOutputBytes(). + */ +public final class RandomizedMp3Decoder implements ShadowMediaCodec.CodecConfig.Codec { + private final List decoderOutput = new ArrayList<>(); + private int frameSizeInBytes; + + @Override + public void process(ByteBuffer in, ByteBuffer out) { + if (in.remaining() == 0) { + // An empty frame will be queued by the MediaCodecRenderer on END_OF_STREAM. + return; + } + + Assertions.checkState( + in.remaining() >= 4, "Frame size too small, should be at least 4 to hold an MP3 header"); + + // Get the desired output size for every input. + int headerDataBigEndian = Util.getBigEndianInt(in, in.position()); + int frameCount = MpegAudioUtil.parseMpegAudioFrameSampleCount(headerDataBigEndian); + + int expectedNumBytes = frameCount * frameSizeInBytes; + byte[] bytesToWrite = TestUtil.buildTestData(expectedNumBytes); + + out.put(bytesToWrite); + decoderOutput.add(bytesToWrite); + + in.position(in.limit()); + } + + @Override + public void onConfigured(MediaFormat format, Surface surface, MediaCrypto crypto, int flags) { + // Both getInteger and getString require API29. This class is only used in EndToEndGaplessTest + // that only runs on + // API29. + int pcmEncoding = + format.getInteger( + MediaFormat.KEY_PCM_ENCODING, /* defaultValue= */ AudioFormat.ENCODING_PCM_16BIT); + int channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT); + Assertions.checkArgument( + format.getString(MediaFormat.KEY_MIME, MimeTypes.AUDIO_MPEG).equals(MimeTypes.AUDIO_MPEG)); + frameSizeInBytes = Util.getPcmFrameSize(pcmEncoding, channelCount); + } + + /** + * Returns all arrays of bytes output from the decoder. + * + * @return a list of byte arrays (for each MP3 frame input) that were previously output from the + * decoder. + */ + public ImmutableList getAllOutputBytes() { + return ImmutableList.copyOf(decoderOutput); + } +} diff --git a/testdata/src/test/assets/media/mp3/test.mp3 b/testdata/src/test/assets/media/mp3/test.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..77ee5e9c5a362cf4440a21621dc1e3aab425ade2 GIT binary patch literal 8586 zcmc(kXH*ki*Y_utBm@jSAYv#YEs{{IsG;{udKC%1DkuU93ZeI=AOS^zPz0qTDhL6j zsX>TT3q?_+2+<1!sd<9;`>geTe?Hvj!_1jAYi7=G&ffpM&pw7as!(9BNn2T3>T=He z003g*65^?T{D_+R5h8)`_wIiV>@8CJ|9APHuHhxm08Wqd93Te(l1>0OHyU3U$<58p$0r~lASC3*jT_O?iHV6B896yQckdP# z7e9PhU0vPS*wWI{(b3!6+uuJtJpAFq^z`)X?9$TG%F4#Z2AjS2WzHboIDefP*P_&ewm-ReJ7yruowo$PsB@ zq|#XId?TxIKJ~qZxV;(~A2X=M9W_|!92jn<6kLkC1Q2xp<~%$bxy39`!dUnOv595$lO0CC1|uxmP#ul=(v z06tjjQS(xo*Pve4Z&y3vxJS}0MRulul#*_$F@4ubMOh?Er}vMV5KKp(K8i}rfag#E z@n9P|{TW3LWddh!S#-fEC@Z|s=^p^d3+ZD0U>*e$>@eeF@KQ|L3->dKabNo~QyD2I z8^1(Jplv$Qod&!N-YApyOfZlP%d^xQOK0k}<;(AcMJ}*eo2PenxQ9Mw&(DVdz*#%@ z1noWns2edT_~q0Lh|CoP{vjaEZFv25DCPJJY@B;#9Xlo6=eW zH7p0`oQkvVT23lF@Ksv9q@A=i;~5zlbN0YTcS7Ru5*|Se(2~4$lW19>({zR)5d*{T&xTGu{?7okpch)OIO#| zOZjcCqHyr@(|pq@CG1p5vI>tE3Dg}eBjCx0L7PGsJGq~oX!0)gSvfyYx%sZ9sJdnR zR;AX&V98T@-lbgn(%6nrI{@H7;WbPvpo`Q6bOgR@2iYYR?>oSFA2;pa(dLa2jln>O zFSGN0?g0sbxG{JhJlhxKhLq&Y5TY(4iHbE06wz?G*xz-SY4Gt|C6#97ccEayuV%UV z603abYA>nYDq+~vI#T=0A7J0?Y6AIyClG(Um;|#Vpeba6yrwdtntJA#eoO_ykLDD?E>E;Sje=9UqO_t5;~>Nhg5K>-RVfIFAWgY{GEz;9_y#X#pn4trzRjWA zdJjkpKzif(@N5N;VUncv0FyLgR$nn@X*sR`&^Sx*HRHcb_-sh`L>VHphfPAJjqVl3YFkS9{Iru!v z6W&o>O*ZF^v7U2iD3V?qmn`+x9U*GPZw8N8oK}3qYW^0HvEHD?_NZVj4sG)e?g7a| z3@Lc@W43|02GS+!0TTtN%FNgYH3Wgg;W1z`Ul7v6k><9oW(gfewQFEVgPGh5veVC* z&_ulP;^IJ6cplByDdLUmMn;f7yKmd_rUdKKwnYn_Hopo0!cS>QGAp;9F+~g?Ka}@Q zA0ZT|YX@We2>O5|j?ZyWrmNqr>QVi2!RX;1^(~cO`Wu)-=UDUX9}56ZpS=W@E`(BO zlI^71V%%zhZqr;_KyR6-QA&xfysU7{nn}|7V2j0~zYFT4sbZ?V*suXFQ^2#LlRC@N{?Px#p&^mDQnT+D^E2sIlT! zOD>zez%Ggd0RU8A@v`h>sLjmU0*Ud*odEAaE^c~9h{Z#uPulMR*#L+_yg*u{5l93n zCCLHmhIsA!dg0}m^-#gm7JEdfKl~`Wcxqy3ylv5zT=*3$*zor(n+4Ol^nidJ*VxOKC z$v%!#k?Jd^xk4EAw(*)^K{Yu&&DnH#VU06$ zp%Ytl`qL;}|1%+BE<5U6t|ryk`6S)59j_ixu=ow(Frc&#-^*PB=45005Cb z5_)~pH*VhA9eSs)J5e?MpMn_SImNT}{4{QgvPPG%hvCyuBhj*B4Z3Q|^E(k!%ttKt z=!3rUL{Nf5B38fBy zV*C*Mo0B7NqK2xg=;b?OpK3l#F0WumTU=Ju2lsJv&<@k`MKwQyh`aB7l;Tp=#I}QR@G&(Tm-RhRhiV9zY5eTMI3=|3zra5=tt7K@*~2N36Puo})9*}n zSvCcxb=LrZa^U;?*(kkl&!Ch_t&u$Qx~{KOgTC|oc{X8?PzLiFBaL2dQT&8g|9qJ zPN;g%4sfW%?NmpMQqQwtyH^JD8b z+4?QE!{0XjIX}=({pKVqCn;&XxSHfmP;&26v>+$-_T1DtNha_Bd0Md|JHZY5ZOMI2 zZ8@PMyOmjN_RXb;#_{E4pM2RfcP%nGkpwuk+1-+$6b&gm0Kpa>b(6|Vv+i-2r7B*I z6&;nTqzd73KxMmNC%}gwBQ7i@hH0AoFcSRj32{+Eqs!!tVX_{vUyBuF{@wmusewn* zQsdM)<26UOI>HT7ZN7+q*!dmqL;+rXN8E zI|_hLGqGuCwO&XnfNlK|Z9BQhW^eUJPE9|l_Vsy7b7Cw7ORUPHnk`V`bqDSMo0sQK z=9K;*I47U{{A7C)kJ$tI2|~LYBacKRgWTeZtOw>v93K~VsAjD_F8l(8EEY{pq`VCg5W6BvGWXzfx@XL+`*lc7azFKM#jg^zmH@7CErC82%4wcxsLEfjA5^HmJ6vhh5VB&fEr^%;9+N=P5geOvdb8D?MAStq zD8;r-c2Q^O;!l{<( zN|48dGn8IPE&-iY+unJZ`v(rmA7X1e>SLL|hAp&N-J-^KreCiKm@?&}o#wL`Gk@2% zmewK`WzcH-+t*Z`J*yL)_Hzhj_xiB9UwWE07=aXk>Il1GkPWxXe`?Mkym@}AzcND? z3c=z&BhU~MD3GRBZoS+h@2ej6z_H44ZDlx}YMSIz=fy4Nw8F_PG?c~f;sYyE^6!uC z0kLA<1_ncUBD|yYV4pQPCU-GNk2wD4N&a?((n27>rxngF*~TaK0TdZ9#!U)-_2?C! z9{%>Po_%IGJ!CDk@VU+~7)c9D*HABL8Xl^dr+^4;uCBQgTmH!o56Slb)Ute(+0E_o*7DJng=JmkjYyM=XzB5coJ34R%jdKvkQKdcs z{xS{$Jo!=}9(PNkFlX$n-etP)m?(Aq;)E7;oHhN={obV@p@^}R&LYa5kMz5urJ`st|? z>*nq$fx;5vmc3?bzO!!ZM0&5uTjnPR)=92Uk^&#g+sFNEnliez*xa;l_ik08-@)3C zRl8ozIZ~%KzaG_S;>S?00C4 z6M+3Dd8m*0B}?a>qU(X+A9j!Jr7E0VeI8lFCG^t1RJJ~xU%w?9q4aC8e02}#CxA#) zJotDEB_$+Zs995hy{M`M>;?!Q>{XTyWx##}`*ucOC^`y*(yA zO$DPkF&5+zzWL!CI5g{!$PwKYOmYz#SR#Q=?Yy_L&%p{Skj+Kiy+9R|Ww1*$2KGE*IY@Fyi%&FG0YaSM&af5* zFa%sCk9E=gnH?5uV`q%HFq_X)szqqDPk-K2nl60pYxvQCd4J0_N&wAAvM=mVZ(>2p z;n2tnLuw-&F4CcJc71!7&LMlHsI;>(1}JF=4g^8RY7WJwB53ebU?mf*Mz9n^0T9X4 zRQ=&M;!IpK{=k$)+q2iAUd21>pX&6K-`|g1IiR2>7dPC|Z+!Nz$$?Ual@UiGeL(?8 z4iJ;npugK>!0#SK%EI8<=ZRb&4LSxVH)!X=8L45+FD7@k*kB_HBLX&hXz+ecv{S(t_^%RMlIsx+UqMa zKpqB44m=S9f^-lmztRRg8=qk>R0rMt=}N>Br^D{KxzO(CnG+KcKrFLkY$xE}&LX>c z$9j0~8P4V$FNbp>WM*>!4>?Z_16QB};!T=b58sVk zWQR>uqdv^uSg;P!bnxK|*$zamY#EqDNx-07 zCPxs2XX71x0$lIWP}#30M>V<8AmD_40BnT91qWn#Y5N}3m!a)E=i_~P5C2BlZJuTi z-FtrWqX>v?d}^kqn~{8O$K+Uz2?dZ!Q{0()O4s!1|G_TohVFSh?^+nz^8fXqB09h5)=6$swa2IFf#`SF9 z3b+KW4Z?u=aPX|*dV|dwl|u^|N|bro#2-#u3T`8nscuPVpN6vDy8)-_6rNx_2J^Or zbCcqq4ExhPXqatl$i?8WvfpJHv#VBreP|K%=7%w&`hQJsNy|Gi+7ugC7H2+?NY(}3 zlJ7*#xt=`K9BK}G=%NIYf)5D+G$`5#1i}rPp!dstv-K)0_b@-|z?$&=I5MD=i~B0n znjvzuk@dbU?`dy@T+Pz{x_L3YzmxQr){zvN5R*Nb0>|99wsRE_qD{nr27$}d38|We zUc7bf$PO(z;B$W<_H`FSP=|zQnkg7%vmIEW+ba?tQZ(DJ4=uLg6Uy(mZiZCdR+p|{weMyuRUXle zzmQw#d!Tl_L~*q?MEMp4&<~$HCwSylOc8{}4>d6{CFDN?jS&FpL4sdgFaUN(#t~#7 zJO?1$8EEPKp4;jPyL5X0j)0{WtIf$Nm?}PU+n$SI`_JknA=Kc2E-!0lYt-f9yR6k? zt82|}?1iD3`vvmzkScY44jDVffkZv`7}m*n#Nzu zBA&^|mrA(Ifs5TqUpi?d)45+V=dV3{SZot5Vk;+iG10-3mmh0t5Q<+7NX@mjae4Xs zMv%miamwvM9XE>t?AC%Gr`(3f3XjZROUGJe7MeleFrsF|VGZ>~Rk+&1jCQKj3Pc?K z@{6h(+-Qp^66U!0`2O!@ua8^8Q=u&zzWc_;t`*Z8UEL>{lkz>0I|KlnE-<#fmOU+5 z1jxx8-ve3#km7hDQ_cdfuYmk{Cn>QL!bpr|zU95;-kGL?`zXof-eC!{TNmPO6xYNH z!%B_x$`q62EO|To#9eeULJN(ivj>Z zvhum$EhSKDG(Rjk84Fy`LgYU*|He&qcd-_i#mU9Ovk^3kUQN3mNR);^Nsl5;m(?yKmd6v19Gn#NP+yv1hIK%&EYTV5;nrovf z=Pyu9%DCZQQAmv|?BK{Ym|;gaA}cJ0<>A*nYN5JYPuLvDJ!#IVHpP;zpQ33yNvnLG zLGuIA3xdX8 zL%I}z>wD`h)J=rT9d|5NG&YNp3^9zNqRivdnH$|#4tB&`fzQ7sh+ThvM*vHSilNYE z=o67A*!0U8$b%KBz~ zmwe#`n2SwIk$I}Il|FNmAjcbt^Wrcg&XggeE&?QD0sk% zN;&JH(mHov<1(DcN|*u|`%_Ma&;e(h4}5FN6Xm~l9jzvFnF3Ri9bb291M*pc*g;#?C+J(ZhY(2r6h09}SciV(*CoxIq$>kN<=Ty+Phczpc#S z>F<>{o@>u5UpRHnl`^N8N3U@7bhL)43*nZJ|6Ss~{x{oRib7vCM7`z2A&(@Ck2iZx)Y8#M4$S%D^$%P?MDB>%Il?Bew$|} zdrfvrj>Dd^j9>2!cGkM}XVk8#8?ddZN=Q}G;=@pM8z&X91;b{2hN_%$IahewnU}_w zr>ASjyMP8Bz77Q%a@B;#;7jU#O$QTrp$R%(uq!jwD)uEwC!P<_Q0uF|pfFseFrQ6l zg&Rp8bCPGfR5Wp*R5$?8xka__*Pw{z+dvTJlL84FTCg0Lu{kB6#USck$dRXL2{WBK2? z?>Yq(89072s22&cRs6vs_woM@E}h?=KpCt;Gyl9(@P+LX!HsqOVY^-e`avK;&#KW&FmBkjqHM{#sQNxEUrDNfO|(md1*AXMhe) zf1sAeUv`YR+9Athu5mhxdRM0c0PiSkK9}`?AU110$p*_1&cmpQclSx-;yS zQx5qYjX0^;pnY~bLY*HDh;{*5{I?Pfbd`u7FZXFCzSh!8xh*yHBiKwKu(sr-DXoA1 z;Y5jZV&6cGW(lxLEv!t$1#+}fAEIdvg;b#CdHMWi9LMJ+x;8>mg<#eW$)ajyAKC0} zy0@{N)E|7jQ6pJI=*u&9B&Q(f98V7Y7an>jeEWK<2b62-B&uJn!4Q=WQxV?_JHPM-dN%P#-VH^uA}A^#x~{h6>B3MVIDCn3!{ELxqo6v zOtQ~(#h{g1p$}=SSbw+Hro!u$;~R;jNcQLP5UY91Rs|DHQ(e`)ee}xzYTE;vrJ%1W uql9(Ryhb#yLR$kMI1iKK?&XmJxse literal 0 HcmV?d00001