From cc1f4ba81c24ff0928eb336507ee20b800d5946f Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Fri, 2 May 2025 11:31:56 -0400 Subject: [PATCH] [WIP] AWS Nova Sonic service - add a hacky way of programmatically triggering an assistant response --- examples/foundational/39-aws-nova-sonic.py | 18 +++- pyproject.toml | 1 + src/pipecat/services/aws_nova_sonic/aws.py | 92 ++++++++++++++++-- src/pipecat/services/aws_nova_sonic/ready.wav | Bin 0 -> 23484 bytes 4 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 src/pipecat/services/aws_nova_sonic/ready.wav diff --git a/examples/foundational/39-aws-nova-sonic.py b/examples/foundational/39-aws-nova-sonic.py index f08cfad04..07670f75a 100644 --- a/examples/foundational/39-aws-nova-sonic.py +++ b/examples/foundational/39-aws-nova-sonic.py @@ -83,12 +83,16 @@ async def run_bot(webrtc_connection: SmallWebRTCConnection): ), ) - # Specify initial system instruction + # Specify initial system instruction. + # HACK: note that, for now, we need to inject a special bit of text into this instruction to + # allow the first assistant response to be programmatically triggered (which happens in the + # on_client_connected handler, below) # TODO: looks like Nova Sonic can't handle new lines? system_instruction = ( - "You are a friendly assistant. The user and you will engage in a spoken dialog " - "exchanging the transcripts of a natural real-time conversation. Keep your responses short, " - "generally two or three sentences for chatty scenarios." + "You are a friendly assistant. The user and you will engage in a spoken dialog exchanging " + "the transcripts of a natural real-time conversation. Keep your responses short, generally " + "two or three sentences for chatty scenarios. " + f"{AWSNovaSonicLLMService.AWAIT_TRIGGER_ASSISTANT_RESPONSE_INSTRUCTION}" ) # Create the AWS Nova Sonic LLM service @@ -117,7 +121,7 @@ async def run_bot(webrtc_connection: SmallWebRTCConnection): {"role": "system", "content": f"{system_instruction}"}, { "role": "user", - "content": "Say hello!", + "content": "Tell me a fun fact!", }, ], tools=tools, @@ -151,6 +155,10 @@ async def run_bot(webrtc_connection: SmallWebRTCConnection): logger.info(f"Client connected") # Kick off the conversation. await task.queue_frames([context_aggregator.user().get_context_frame()]) + # HACK: for now, we need this special way of triggering the first assistant response in AWS + # Nova Sonic. Note that this trigger requires a special corresponding bit of text in the + # system instruction. In the future, simply queueing the context frame should be sufficient. + await llm.trigger_assistant_response() # Handle client disconnection events @transport.event_handler("on_client_disconnected") diff --git a/pyproject.toml b/pyproject.toml index d6d05c00c..7ce167d77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ where = ["src"] [tool.setuptools.package-data] "pipecat" = ["py.typed"] +"pipecat.services.aws_nova_sonic" = ["src/pipecat/services/aws_nova_sonic/ready.wav"] [tool.pytest.ini_options] addopts = "--verbose" diff --git a/src/pipecat/services/aws_nova_sonic/aws.py b/src/pipecat/services/aws_nova_sonic/aws.py index e7b1fd8e6..5b69810f3 100644 --- a/src/pipecat/services/aws_nova_sonic/aws.py +++ b/src/pipecat/services/aws_nova_sonic/aws.py @@ -8,8 +8,10 @@ import asyncio import base64 import json import uuid +import wave from dataclasses import dataclass from enum import Enum +from importlib.resources import files from typing import Any, List, Optional from aws_sdk_bedrock_runtime.client import ( @@ -146,6 +148,8 @@ class AWSNovaSonicLLMService(LLMService): self._context_available = False self._ready_to_send_context = False self._handling_bot_stopped_speaking = False + self._triggering_assistant_response = False + self._assistant_response_trigger_audio: bytes = None # Not cleared on _disconnect() # # standard AIService frame handling @@ -180,8 +184,7 @@ class AWSNovaSonicLLMService(LLMService): if isinstance(frame, OpenAILLMContextFrame): await self._handle_context(frame.context) elif isinstance(frame, InputAudioRawFrame): - # TODO: check if _audio_input_paused? what causes that? - await self._send_user_audio_event(frame) + await self._handle_input_audio_frame(frame) elif isinstance(frame, BotStoppedSpeakingFrame): await self._handle_bot_stopped_speaking() elif isinstance(frame, AWSNovaSonicFunctionCallResultFrame): @@ -211,6 +214,15 @@ class AWSNovaSonicLLMService(LLMService): self._context_available = True await self._finish_connecting_if_context_available() + async def _handle_input_audio_frame(self, frame: InputAudioRawFrame): + # Wait until we're done sending the assistant response trigger audio before sending audio + # from the user's mic + if self._triggering_assistant_response: + return + + # TODO: check if _audio_input_paused? what causes that? + await self._send_user_audio_event(frame.audio) + async def _handle_bot_stopped_speaking(self): # Protect against back-to-back BotStoppedSpeaking calls, which I've observed if self._handling_bot_stopped_speaking: @@ -316,6 +328,14 @@ class AWSNovaSonicLLMService(LLMService): # Start receiving events self._receive_task = self.create_task(self._receive_task_handler()) + # If we need to, send assistant response trigger + if self._triggering_assistant_response: + # If the trigger was the first audio chunk sent on this connection it'd be ignored (I'm + # guessing the LLM can't quite "hear" the first little bit of audio sent). So send a bit + # of leading blank audio first. + await self._send_assistant_response_trigger(lead_with_blank_audio=True) + self._triggering_assistant_response = False + async def _disconnect(self): try: # Clean up receive task @@ -340,6 +360,8 @@ class AWSNovaSonicLLMService(LLMService): self._assistant_is_responding = False self._context_available = False self._ready_to_send_context = False + self._handling_bot_stopped_speaking = False + self._triggering_assistant_response = False except Exception as e: logger.error(f"{self} error disconnecting: {e}") @@ -490,11 +512,11 @@ class AWSNovaSonicLLMService(LLMService): ''' await self._send_client_event(text_content_end) - async def _send_user_audio_event(self, frame: InputAudioRawFrame): + async def _send_user_audio_event(self, audio: bytes): if not self._stream: return - blob = base64.b64encode(frame.audio) + blob = base64.b64encode(audio) audio_event = f''' {{ "event": {{ @@ -639,7 +661,7 @@ class AWSNovaSonicLLMService(LLMService): elif "contentEnd" in event_json: # Handle a piece of content ending await self._handle_content_end_event(event_json) - elif "completionStart" in event_json: + elif "completionEnd" in event_json: # Handle the LLM completion ending await self._handle_completion_end_event(event_json) @@ -839,7 +861,7 @@ class AWSNovaSonicLLMService(LLMService): ) # - # Context + # context # def create_context_aggregator( @@ -855,3 +877,61 @@ class AWSNovaSonicLLMService(LLMService): assistant = AWSNovaSonicAssistantContextAggregator(context=context, params=assistant_params) return AWSNovaSonicContextAggregatorPair(user, assistant) + + # + # assistant response trigger (HACK) + # + + # Class variable + AWAIT_TRIGGER_ASSISTANT_RESPONSE_INSTRUCTION = ( + "Start speaking when you hear the user say 'ready', but don't consider that 'ready' to be " + "a meaningful part of the conversation other than as a trigger for you to start speaking." + ) + + async def trigger_assistant_response(self): + if self._triggering_assistant_response: + return False + + self._triggering_assistant_response = True + + # Read audio bytes, if we don't already have them cached + if not self._assistant_response_trigger_audio: + file_path = files("pipecat.services.aws_nova_sonic").joinpath("ready.wav") + with wave.open(file_path.open("rb"), "rb") as wav_file: + self._assistant_response_trigger_audio = wav_file.readframes(wav_file.getnframes()) + + # Send the trigger audio, if we're fully connected and set up + # NOTE: maybe there's a better way to determine whether we're done setting up? + if self._receive_task: + await self._send_assistant_response_trigger() + self._triggering_assistant_response = False + + async def _send_assistant_response_trigger(self, lead_with_blank_audio=False): + # TODO: if/when we make bitrate, etc configurable, avoid hard-coding this + chunk_size = 640 # equivalent to what we get from InputAudioRawFrame + chunk_duration = 640 / ( + 16000 * 2 + ) # 640 bytes of 16-bit (2-byte) PCM mono audio at 16kHz corresponds to 0.02 seconds + + # Lead with blank audio, if needed + if lead_with_blank_audio: + blank_audio_duration = 0.5 # much less than this and it doesn't reliably work + blank_audio_chunk = b"\x00" * chunk_size + num_chunks = int(blank_audio_duration / chunk_duration) + for _ in range(num_chunks): + await self._send_user_audio_event(blank_audio_chunk) + await asyncio.sleep(chunk_duration) + + # Send trigger audio + # NOTE: this audio *will* be transcribed and eventually make it into the context. That's OK: + # if we ever need to seed this service again with context it would make sense to include it + # since the instruction (i.e. the "wait for the trigger" instruction) will be part of the + # context as well. + # print(f"[pk] sending trigger audio! {len(self._assistant_response_trigger_audio)}") + audio_chunks = [ + self._assistant_response_trigger_audio[i : i + chunk_size] + for i in range(0, len(self._assistant_response_trigger_audio), chunk_size) + ] + for chunk in audio_chunks: + await self._send_user_audio_event(chunk) + await asyncio.sleep(chunk_duration) diff --git a/src/pipecat/services/aws_nova_sonic/ready.wav b/src/pipecat/services/aws_nova_sonic/ready.wav new file mode 100644 index 0000000000000000000000000000000000000000..ca932afa66d69dcf3626a54f9b171c767eb4d0e9 GIT binary patch literal 23484 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�+!;GJwq@jJ)|Up4p|`1q`D>$}E=P5s zPSQ8v&3P)(2qjBTq%tUhUTvIgK41zpyrrIzLFQ@e1;1tG)ABc@b zK?oBs!4+wo;1Z1DX3-%ollDjkDPLG2%oT4+D`m4nD>IZSN*9>lcDtqOsa%*X&_%$R(6hXID93lQH-W1=6?WLabJS9t6sU#_* zl@HJhOvbkm2Z^1;4&pgMkw?j)R2*$#4l$M4-7ICOWvF7H4ZpFtfiPN3YfKYNXN(&S zEe)Lv&kb9QeT^Fo?bx|YI1|m>r%zI?Niz|GFNM{`+NgqZPx?*F5f%zvg!hEQ{wDtG zd<}jfzl}f7ALgg?FMI>|r~F0#Sz)61NVwzw#J|ShTuhUeD&x@$bVli_dsBOk4B@3iXySnZjlq$NFTzxJ_f4>^`x`@%lvu&ui}2Gx|AuD7bl3XrD5_jd87QJ zG6z;E&ZD{LCaeVgLViwpsDAWQritOM@sOzrtOsl}PB+ao@ur=o{iXHm^W(Ak=(V^*s4a{W z`=C4YU~`&nT12@LPvfVRbcFA-&Is!h+asY-VnVzVW1qU?cwtt8y=n!PnVzj{JFVLlrd=l|HV-qVin=ld~pM_9Mv|aTzIMQCQ(CT$A(u6dk|HoOhmaxrGJgyXggwT1S?s`ZD%8W zq4k*a!e@LFaezEeCeQV0bR!16HAFjl-1Cm z8AzNUt{Y!iaocOtDZ^FPK|LaxFkV9yrWt-f`APKod;5Fx*Z7NkB`(Ev)79GjgZHL4 z-_yuF#(l>7J-65UwRfp6nZKYg^3P&ZrM_Sj`Y2`&}kT8>*DbuWB#?q^pJ#XMXx|turZ{d1#`}|9Uu0kvM zeWkJTJMv3Iq(*X6X|S@394YL|eQaJH{#B?cOp?|*a)o5nnQ&WkLOziroo90<=O0wk z$m_mx?ilBjobB2B-hP!;&%KWu>)htr>Dlet>qvGE^-Uvkj0+7_s4vKdc)8@Ms&z&6dY-0&?Kg$(ld*bTZZHCDRi z`^~qNf8?v|A1RGcb_=Vy(cDa-rZQhSrX-<-7>-9{AESZzcKi_D0Uv;E#BZT<(p0g9 zd;#WhS^p_+o^Jy`-oFUieV0q|u%1hvcE0ES95K^BpLck=`D%z2<+jp1$u0I*epkMb zPs2KHvT{M1YrN?^;JRtK7TzrMxUIFXop+G3hb+ssvQM+D=*d?(ouOhJeyoos^KHCaOIeM0KI?}{<|m=JwcC@cJfeB z61T(scf23t7x5i^jkq_SCEQqkoiCM}>2-Q*a+|#Uef@-Xq8+X!Ck0uypr^bfw3KlC z6LJySC|4H`NlQ@=REvA$&bCouhwR76&F<6g8{{P8Nz0ekr*wNh(Rqt|iFdU9VZ6v! z%N^ou3Zra=KhM|HciqS4FLRFf-1g1zZ}Kk{(v_?71;Of1!hbZJv`(}z#^r2jX0xeA zXzjRfap@#0-6N*i^Vln76S@p(AV%QLh)^O` z+U8@q7+NNFb1@+cUG_qcE>Dx{2gp80~Ta1+o~%4U3Jx^JkA z(z)Kg2J$HOvN;q#;$H3Oa~yn{X7VzE34dl<7n=5UlL?5zD*`0DVI5o;o@Msy8bZkl6Q zW-b#l-_na+L(V1{d^p+xPeRktDlyL|a*2Ej{~_1kGoPEonY@GDcK1l{9^YZ#SNupH z!JXxP_RSM6NzbJj!a08rakcUpd}*tR&MBXvL0Db<0B$Ef!8c-mp=NkP;x)bw>xmMS z+6n_-4R)Z?7?P9aZ=|IXFOHUM*ar9pby{Y`%JKs28>OF84Rx1m$sa1~#3W^e_*xk+ zua+CR-;=wVY8opVGNirySz!^p;axHONH50v^NsoU6dzl`cz~Gh%5r|l50&aGP33L; zN^iEyocqxGwfwpGId{VKr?Wio=c9yC;#uVnY^>B)TC0qsW*c@JPnr6dT}FqIH%azg zQSQk4p`)x$TjTI8ktZXUMLNQ(+0#u+%@eG6=uX=Y#yeCxIfht(m%=NO+c8X<>TBma z#kb{4@b7bB+i=yb9fK) zRD3G_8P|r3ktO3uJe$nIcMC_|jF?Z@*`!pg0w!6YtR;{37uMJK0dvaDZuR4zHOUA`=TDTgUF?clLhL4-7Jio(`i$}! ze3{`9<3byFud^}QOMXOEpu>nvUjw)z%s@Aod2E<8+tJ3E!Os*wQ=;Y0BIA1IsOV_n z?uadyPjlZmmU+5)clrCvZ55BWKq)O8_tznQro+iH#0S&^<^ubTVFFW&O|r}}T{k4K zaZojSSCm5vR;&52)dMM6PaQrdW1K)%{z;EFs zxr_>-9q{$DJpC>0rDBo z=`Ktw_AI-IUC+#7&QkHrC3t&S!y1?$sj^HrdN1<>-JV`VPX+B0=*o0&CWWfTj9?d# z<(O$Cf_C>~o>4c67!+k-sgg_!>OK5CX*TsH#?Xi13-@Rug1CuQpiaSa*#_}1=^7e< zI!oPsKY2)gz5f_biqCw9Tq*7??xyZMUf}w{(|J4heOG5+Gv6KlsL#ih@jvi=Bvh9^ zm2OL$lq9(__BCpcy-$$D6QU}45$_M{qm7BZutu7VA16|=viLKiGO>-QLrlcY*h16@ zO~5{bFYd?T>o_i*6n2S>bXD9c*!kJ;O?;?-qCbs)?W@i8@uqoK`99@W@@IXAxF_BO z{v)`%MGF`Fr~JPOMkz|(tt6mtmHSFGRsoMEGKdajEH#RHLxt0G>60|aaLiI>E4z(} zWzR8n*-2~#!x;7xb^vo3Ji34lHS}i@`+{k}_F`pb7?aFIG85Pp&<}m#UED%dVNTEs znI=ppW(S=|_N8x73jLhvz|?g$yeEEwC%#qY$k;#V+}_efWz(Xt{}Q)Vm6l+qCY zvKjjxs}B(m-{AF$i^OqqB( z#8BPvC!5EvgVnlJSX=JFrn4{DPnnkNm(1_XLFObwF%{W~^k0;XK0_Mm=Cqq$Lye(c zlaEM_d`b)>X5jaU2lynS1~G(qACJQ!Dg*V#-VlEf3&}EgdF(OVC$B^M-opC(3HeX? zh15|>5%YyKVY84TRFxhJoH$47vlVn z`J?`9{|D-v<1)nAsiOFiEAZBYAV%|K9JI-H0f8Vx7@p6r669J3{fvfmGg?E zw1QY48vJt#+lWoZzQtMsycIi#{ej)a;vi-r4@-g79R^l;;xP|IoYcfGK+Mk_h-R6J zj-iqe5v0Hx;68|lXa~_MQaIJZczr<@39-p zG^|36k{U}%QVVIJv`e}pCChE(fpSl|g4|YqEHg@5rMc1oo}BDTOT~;T!rGQssfmU_ zELKYhe^11A!Tl-%`wpJD2qKnviT{NEh_Av^@lc`%@eAQ1lF2Z#D_qHT5qpTbWM?uN zo+37r$H?JiU-C1uHu)*JhrTx@y>Lj&>T0;Fq?WFwFb!sGBGjieE(kd9+17UQJA&-#Z)FbftL~;dbfNu$t zi3>zG;x4`rENg@x#O}hZehRbrV~A6Fjbb29W)r-Je5`CzzEUK4zI;`_Dc6%jRKN#Ma_Mv5uG}d;;rGm&M;;P3k#Z3;q=U5*2Zh)JiHPjg>A*r=?EtW>+3O zUqxOkQ}F(=MYhAb-~w2E+oTv#M~Kea1<`!3U<_mMUa%Znh&{!sz|;IZ@cBzfF+>|8 z2SOaz6ZeU~h%)41av-^pr~;ndPJRoA5B%Mg{F6+EFRkB@<;Y&-RdN@M_7LhE*@ApX zM3XzowPY7^15goYSV7()+dyPPHL?_}7>^-mkb}v_WHWLh^j~-KJJLgtWJhuU*_<3l zjwct8H;KbU7vTM#I6~ZnJ5^btD)_zuaRa{sv1oO18{PsRh)XbgXF@z&DTt)23^Vrv zT8wJLEWnT#-kHzCYG^rFUwtXN<%{wS`M5kt9u1?iw_HlTEu~0V(ne{wv{zan&4;T| z2Y8C^BsGU?$S0B_CP1kNTw&`=gQd@<_AsW8O9!N1q(@SHh=GcLC)O3<(~0l?h;-U5|1OtRx+!y&UU04iPo9j@UzrDKnX*IKt6YJ5;{zoOR(k88_7JIe5u*0) zplaA4xQ;A^w~5_wZCVQvbq8TIO~qbgBk>p<$M0ducpJPS%!p_3J~9xlGC$)aQ5SsD zgJ?=ngo&6&q!71>h0tfi0iH$(L>!q*%pm3xM~SnG)uL1k^YJZ;8j_Y1kbs1A7c< z7d8dr4?nB zs6r?_ta4XVN-3dmJ;;aq!E@*%T*(0sPM3d@f00kgX|hRq3hRDf%2T0qQGO|t%3b-6 z`~tq+*x)|NLtYK8CGC_6%4&#k{92gxJ=8vF@f2J%|aTRreT@P6_!U?$_E@b(Zh`8`1M zU%E<-C2N3<)84X3Bz*rxFra+9~Hi!~D zhJJ-~0;NNgumpCiQPdbj+tz`5Ya@t1?1pv3hQU2RjdvXdGhjBfeI2$Pjs@6aYyvaH!X@kuY}#VSOQu#KuBI>oBYzM9mI`V=OiV;7Q=k zZ{Y4f7j%9Dv8B7Q1K5us`5Sfw;62ze7~9LR)!07l5Vj4VMZh-+dSNzUZGS2h`ueTfd^MFqhVW=YB>%qMy(Pv;r+eYoKqJp(SW7 zJeMqlF+CSBi_vo6{0X=gqb;C$FWLd}=L_PXuc2oUPx}z1K%NCxd^O(L0P8;-vV#2~ z5W8FsVvh;PE5p~VaG1+#)NLq072&=X3pDlWYK3Q_WZ(>ksBII(lb3=c0a7GTqJggr zL@*~pFIEBRWUMB{NY}*bL7%<{zG?|$wk7m#b2!?==xqt(_XDgQ|t8#4w#!z{<9#W{1JSs zdI-_wFG0&|csEJ~UFiV5fpR9?HFMy|1b^m0oU$63?*P0LqT4Z;k?Qh=id7H=(d2%J z@24TkTaDLO_XAK6wPY^vqB?Rc;L!+(4i1A{jna<i;~RVWfPsj_MwRiN7reXH&dP`zgb3sfuASbq~(qDJ{EFe22Q zQtF-p3G7vOl&E_RN;V?vPM( z5$scUPN<$%z-D#Vi@IY$ZHc-kL*2hX6|{(ic4~VhRKL^Ubt9Y&kQ<==hJsO~_JN8w zftS?zp!SWrkHQXfzzSv6Yqo-1#aRnrb@zfgb8H1ZR7<7;U8)|{uZI8juUfyl-$AQS z9S2kaP3;AB_lUM@MB6{2_JWF2cblkE+Wr&u8d@M*^N%buM-$~`ylUPuPwo64Ws9m*1wL`U}AcI_m z0*87{5ByY$ic!aZa8lc)CbiAMdvWw#IXX_uRlfuvt)zNS^_-e?4{EpoR^?Fl<>*{$ zS(6H!HK?$py01o))A_aD(KM-+sQjw+YCE(xXclTXt##UNA>DG#e(kKaT$c~{MboC2 z)sp(t%W57d7k~vc&BGc+TM2KtzxtkT&pS{;L){ddC{}3vnXCIt3P%A zX>f7YYEnh(3bd&(rEp2}U~%;Z+O1Jled_OB-ohH+rE7gv7#}PXfP=Mb*x++v4SFe9 zM#Ba4={x~U08(lHUc1Vp%e-5YuD@trm{XT8$`vT9{3_SK=M2c`&_DHHz;2cEpEL(^ z6~|K?POtet&x5rD;9xJQ*x<6NTPqhwDOz(;$)a>kS8-hbDXw^p!Pvr@f?KJ#K<5b1 zg601`TvSfiS6uG~&r}swdA0Mq^#t_jn%=Fc@F>%?shWai1DXq}0&3CpF88~bV3~hS z4RGpwdS2MB09KO-w5}*cZ@_P!EjNzf1(w|YvrO?Ra-EwXq$@G7F^ck zf+d1sjq_cMt|9nZ8Ng}Qs8RvR;y87BfJ@g=xIM*T1Nw@S4oJKUX`>+^uW; z%{oPY+S1FqWU$VF=ECDi$7{KI)-l0QVU2nzz!|{kI9-!gn~p8c!T_hPMePw?B7iF% zQaN=Ey2bCx7R3}TYr2cWYT9+pYOZ05xJ*%a{w0G+W&~<(9VsuL3Jkai7 zd|_#|R6Jj>Wx?_qtjQI|2ijE_A82QwCfy1>4`?katCtIN>ezR4jqjhh0~TnK!F&ad z0DlB1!PIyCp;5I~>r%QmgR#MIfGQCxB5;_C|L)NR%CV44d5YkW~^(el66rg8sM zJO7<7)iz!B-}4r=^olC=Meh%OQkV-FH(#nCJ(X|$BkB%>ztJ*YP%@^H zQUS@ru+Fba>u}M$sCB6{RZdFfVs;)U;&;K7tv!bXiT3xzD zDz$iye{ZF(BVc{;ngVx8@172nDd|8RDACRTOJG-&mE|%Fz>tE8oel2a65x(!LZ7s)>9Z)xD>1> z0P8$ju9yBNngTlCg#vnYntm11%kSPB0u&tz&ULT8t2vnK{~rEN^cBaW+f#gv?^55z z6qO9-&}&rZr2f-a%+;@bff24lf&U6r^#&kM^JZMCjP_u9Xs>->7I zjRn1=d-tEU1UTP?3QK7g6~?_QQy8PSLd9y50eQ{R;?LT5Ts0m*iviHT{;MVR|0VUP OiQ=XI7yGY0@P7cz=1kiF literal 0 HcmV?d00001