From 76b5f827beb100342bd20c378c8e1a1e35cad887 Mon Sep 17 00:00:00 2001 From: ThePendulum Date: Sun, 26 Jul 2026 05:09:49 +0200 Subject: [PATCH] Fix clap detection silently analyzing the wrong audio track Root cause of the reported 'timestamps off by different amounts, sometimes early, sometimes late' bug: _extract_audio_wav() never passed an explicit -map to ffmpeg, so on multi-audio-track files it relied on ffmpeg's own default-stream heuristic -- which has no knowledge of, and isn't guaranteed to agree with, whichever audio track mpv is actually configured to play. A wrong-track clap is essentially unrelated to the true one (not a small timestamp-math error), which explains the inconsistent, file-dependent symptom -- and our single-audio-track synthetic fixtures never exercised this path at all, since they only ever had one track to pick. Fixed by threading an explicit ffmpeg stream index through detect_claps() -> _extract_audio_wav(), sourced in main.py from mpv's own track-list (the ff-index of whichever audio track has selected=True). Detection now always analyzes the exact track the user is listening to, on both 'Detect All' and 'Detect Next'. Also: _probe_audio_start_time() used to silently fall back to 0.0 if ffprobe couldn't parse start_time as a plain float (e.g. N/A) -- same failure class (silently wrong instead of loud). Now emits a Python warning instead, so a genuinely unparseable/unusual container is at least visible rather than silently mis-corrected. Verified two ways: (1) ruled out an initial alternate hypothesis (video stream's own start_time mattering) empirically against the existing single-track fixtures before ruling it out. (2) Built a new two-audio-track fixture (sync_test_MULTITRACK.mp4: track 1 has a beep at 3s, track 2 at 7s) and confirmed end-to-end through the real app -- switching mpv's active track from aid=1 to aid=2 correctly flips which clap gets detected, which is exactly the mechanism this bug required. --- clap_detection.py | 43 +++++++-- main.py | 27 +++++- .../smoke_test_multitrack_clap_detection.py | 88 ++++++++++++++++++ test_media/README.md | 7 ++ test_media/sync_test_MULTITRACK.mp4 | Bin 0 -> 160244 bytes 5 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 scripts/smoke_test_multitrack_clap_detection.py create mode 100644 test_media/sync_test_MULTITRACK.mp4 diff --git a/clap_detection.py b/clap_detection.py index 1254896..2135e97 100644 --- a/clap_detection.py +++ b/clap_detection.py @@ -5,6 +5,7 @@ paths use one implementation instead of drifting apart. import os import subprocess import tempfile +import warnings from dataclasses import dataclass from typing import List, Optional @@ -24,10 +25,10 @@ class ClapEvent: strength: float -def _probe_audio_start_time(input_path: str) -> float: +def _probe_audio_start_time(input_path: str, stream_specifier: str) -> Optional[float]: result = subprocess.run( [ - "ffprobe", "-v", "error", "-select_streams", "a:0", + "ffprobe", "-v", "error", "-select_streams", stream_specifier, "-show_entries", "stream=start_time", "-of", "default=noprint_wrappers=1:nokey=1", input_path, @@ -37,10 +38,20 @@ def _probe_audio_start_time(input_path: str) -> float: try: return float(result.stdout.strip()) except ValueError: - return 0.0 + # Some containers/codecs legitimately report "N/A" (genuinely no + # start offset to speak of), but this could also mean ffprobe just + # couldn't express a real nonzero offset for this container -- we + # can't tell those apart here, so surface it instead of silently + # assuming 0, which would leave onset times subtly, silently wrong. + warnings.warn( + f"could not determine audio start_time for {input_path!r} " + f"(ffprobe returned {result.stdout.strip()!r}); assuming 0.0, " + "which may be wrong for this file's container/codec." + ) + return None -def _extract_audio_wav(input_path: str) -> str: +def _extract_audio_wav(input_path: str, audio_stream_index: Optional[int] = None) -> str: # The audio stream's own start_time is exactly how a container encodes # "this track starts playing N seconds later than the others" -- i.e. # it's the very thing this whole tool exists to fix. ffmpeg's default @@ -48,11 +59,24 @@ def _extract_audio_wav(input_path: str) -> str: # which would silently make onset detection blind to that offset. # Compensate by inserting the missing lead-in as real silence, so times # measured from this WAV's t=0 still match the file's true timeline. - audio_start = _probe_audio_start_time(input_path) + # + # audio_stream_index (ffprobe/ffmpeg global stream index, e.g. mpv's + # own `ff-index` for the track it's currently playing) makes stream + # selection explicit and -- critically -- lets the caller guarantee + # this matches whichever audio track is actually audible in the + # player. Without it, ffmpeg's own default-stream heuristic picks + # *a* audio track, which isn't guaranteed to be the same one on + # multi-audio-track files (extra language dubs, a separate mic/system + # audio track from a screen recorder, etc.) -- something our own + # single-track synthetic test fixtures never exercise, but real-world + # files often have. + stream_specifier = f"{audio_stream_index}" if audio_stream_index is not None else "a:0" + audio_start = _probe_audio_start_time(input_path, stream_specifier) or 0.0 fd, wav_path = tempfile.mkstemp(suffix=".wav") os.close(fd) cmd = ["ffmpeg", "-y", "-i", input_path, "-vn"] + cmd += ["-map", f"0:{audio_stream_index}" if audio_stream_index is not None else "a:0"] if audio_start > 0.001: delay_ms = int(round(audio_start * 1000)) cmd += ["-af", f"adelay={delay_ms}|{delay_ms}"] @@ -71,11 +95,16 @@ def detect_claps( threshold: float = 0.7, min_interval: float = 0.3, max_results: Optional[int] = None, + audio_stream_index: Optional[int] = None, ) -> List[ClapEvent]: """Detect sharp audio onsets in input_path. `start_offset`: only onsets at or after this absolute timestamp are returned (for a from-cursor search). `max_results` caps how many are returned, for a "find the next - one" search instead of a full scan. + one" search instead of a full scan. `audio_stream_index` (an ffmpeg + global stream index) pins detection to a specific audio track on + multi-audio-track files -- pass the ff-index of whichever track mpv is + actually playing (see main.py) so detection can't silently analyze a + different track than the one being listened to. The whole file is always decoded and analyzed regardless of start_offset -- an earlier version tried to skip ahead with ffmpeg's @@ -92,7 +121,7 @@ def detect_claps( "librosa is not installed. Install with: pip install librosa numpy soundfile" ) - wav_path = _extract_audio_wav(input_path) + wav_path = _extract_audio_wav(input_path, audio_stream_index=audio_stream_index) try: y, sr = librosa.load(wav_path, sr=22050) onset_env = librosa.onset.onset_strength(y=y, sr=sr) diff --git a/main.py b/main.py index 4e6971b..c722f7a 100644 --- a/main.py +++ b/main.py @@ -45,11 +45,12 @@ class DetectWorker(QThread): finished_ok = pyqtSignal(list) # List[ClapEvent] finished_err = pyqtSignal(str) - def __init__(self, input_path, start_offset=0.0, max_results=None): + def __init__(self, input_path, start_offset=0.0, max_results=None, audio_stream_index=None): super().__init__() self.input_path = input_path self.start_offset = start_offset self.max_results = max_results + self.audio_stream_index = audio_stream_index def run(self): try: @@ -57,6 +58,7 @@ class DetectWorker(QThread): self.input_path, start_offset=self.start_offset, max_results=self.max_results, + audio_stream_index=self.audio_stream_index, ) self.finished_ok.emit(events) except (ClapDetectionUnavailable, RuntimeError) as e: @@ -66,7 +68,7 @@ class DetectWorker(QThread): class MainWindow(QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle("A/V Sync Marker") + self.setWindowTitle("WeAreSyncing") self.resize(960, 720) self.input_path: Optional[str] = None @@ -241,7 +243,7 @@ class MainWindow(QMainWindow): self.override_spinbox.setEnabled(False) self.log_view.clear() - self.setWindowTitle(f"A/V Sync Marker — {Path(path).name}") + self.setWindowTitle(f"WeAreSyncing — {Path(path).name}") self.statusBar().showMessage(f"Loaded {Path(path).name}", 4000) self.detect_all_action.setEnabled(True) self.detect_all_button.setEnabled(True) @@ -357,6 +359,20 @@ class MainWindow(QMainWindow): cursor = self.source_panel.player.get_position() self._start_detection(start_offset=cursor, max_results=1, label=f"Scanning from {format_timestamp(cursor)} for next clap…") + def _current_audio_stream_index(self) -> Optional[int]: + """The ffmpeg/ffprobe global stream index of whichever audio track + mpv is currently playing in the Source panel. Passed through to + clap detection so it analyzes the exact track being listened to -- + without this, ffmpeg's own default-stream selection could silently + pick a *different* audio track on multi-audio-track files (extra + language dubs, a separate mic/system-audio track from a screen + recorder), producing markers for the wrong track's timing entirely. + """ + for track in self.source_panel.player.mpv.track_list: + if track.get("type") == "audio" and track.get("selected"): + return track.get("ff-index") + return None + def _start_detection(self, start_offset: float, max_results, label: str): if self.input_path is None: return @@ -366,7 +382,10 @@ class MainWindow(QMainWindow): self.detect_next_button.setEnabled(False) self.statusBar().showMessage(label) - self._detect_worker = DetectWorker(self.input_path, start_offset=start_offset, max_results=max_results) + self._detect_worker = DetectWorker( + self.input_path, start_offset=start_offset, max_results=max_results, + audio_stream_index=self._current_audio_stream_index(), + ) self._detect_worker.finished_ok.connect(self._on_detect_done) self._detect_worker.finished_err.connect(self._on_detect_failed) self._detect_worker.start() diff --git a/scripts/smoke_test_multitrack_clap_detection.py b/scripts/smoke_test_multitrack_clap_detection.py new file mode 100644 index 0000000..947327e --- /dev/null +++ b/scripts/smoke_test_multitrack_clap_detection.py @@ -0,0 +1,88 @@ +"""Verifies clap detection analyzes whichever audio track mpv is actually +playing, not whichever one ffmpeg's own default-stream heuristic would +pick. Uses a synthetic fixture with two audio tracks with claps at very +different times (3s vs 7s) -- if detection ignored mpv's active track +selection, switching mpv to the second track would still incorrectly +report the first track's 3s clap instead of the second track's 7s one. + +This directly tests the bug hypothesis from user report: 'sometimes too +early, sometimes too late, by different amounts' -- exactly what you'd +see if detection sometimes analyzed the wrong audio track on +multi-audio-track files, since a wrong-track clap position is essentially +unrelated to the true one (not a small timestamp-math error). +""" +import sys +import time +from pathlib import Path + +from PyQt6.QtWidgets import QApplication + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import main as app_main + +ROOT = Path(__file__).resolve().parent.parent +CLIP = str(ROOT / "test_media" / "sync_test_MULTITRACK.mp4") + + +def wait_for(predicate, qapp, timeout=20): + deadline = time.time() + timeout + while not predicate() and time.time() < deadline: + qapp.processEvents() + time.sleep(0.05) + return predicate() + + +def main(): + qapp = QApplication(sys.argv) + win = app_main.MainWindow() + win.show() + qapp.processEvents() + + win._load(CLIP) + qapp.processEvents() + time.sleep(0.3) + qapp.processEvents() + + mpv = win.source_panel.player.mpv + print(f"default aid: {mpv.aid}") + default_index = win._current_audio_stream_index() + print(f"resolved ff-index for default track: {default_index}") + assert default_index == 1, f"expected default track's ff-index to be 1, got {default_index}" + + win.detect_all_button.click() + qapp.processEvents() + ok = wait_for(lambda: win.detect_all_button.isEnabled(), qapp) + assert ok, "detection did not finish in time" + markers = win.source_panel.marker_strip.markers() + print(f"markers with default track selected: {markers}") + assert any(abs(m - 3.0) < 0.5 for m in markers), f"expected a marker near 3.0s (default track's clap): {markers}" + assert not any(abs(m - 7.0) < 0.5 for m in markers), f"should not see the OTHER track's 7.0s clap yet: {markers}" + + # Now switch mpv to the second audio track (aid=2), simulating a file + # where the user is listening to a non-default track, and rerun + # detection -- it must follow the switch, not keep reporting the first + # track's clap. + mpv.aid = 2 + qapp.processEvents() + time.sleep(0.2) + switched_index = win._current_audio_stream_index() + print(f"resolved ff-index after switching to aid=2: {switched_index}") + assert switched_index == 2, f"expected ff-index 2 after switching tracks, got {switched_index}" + + win.source_panel.marker_strip.clear() + win.detect_all_button.click() + qapp.processEvents() + ok = wait_for(lambda: win.detect_all_button.isEnabled(), qapp) + assert ok, "detection did not finish in time" + markers = win.source_panel.marker_strip.markers() + print(f"markers with second track selected: {markers}") + assert any(abs(m - 7.0) < 0.5 for m in markers), f"expected a marker near 7.0s (second track's clap) after switching: {markers}" + assert not any(abs(m - 3.0) < 0.5 for m in markers), f"should not see the FIRST track's 3.0s clap after switching: {markers}" + + win.close() + qapp.processEvents() + print("\nPASS: clap detection follows mpv's actual selected audio track, not ffmpeg's default") + + +if __name__ == "__main__": + main() diff --git a/test_media/README.md b/test_media/README.md index 94f4ae2..f5bf31d 100644 --- a/test_media/README.md +++ b/test_media/README.md @@ -31,3 +31,10 @@ validating marker capture, offset math, and lossless export. Frame at exactly t=5.000s is frame index 150 (30fps) — useful for checking frame-step precision lands exactly on it. + +- `sync_test_MULTITRACK.mp4` — 10s, blue video, two AAC audio tracks: track + index 1 (default) has a beep at t=3.000s, track index 2 (not default) has + a beep at t=7.000s. Used to verify clap detection follows whichever audio + track mpv is actually playing (its `ff-index`), not whichever one + ffmpeg's own default-stream selection would pick -- see + scripts/smoke_test_multitrack_clap_detection.py. diff --git a/test_media/sync_test_MULTITRACK.mp4 b/test_media/sync_test_MULTITRACK.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..d1f6ac25117fc500da54ac9bb2e3e531d0e63576 GIT binary patch literal 160244 zcmeIb30zIv8$SN-QwX7w+0`jcq|ua!N>M~Y#z+%IDV2FB356@lJd|1Hd7d(aOu2^0 zOz4^=TvMI@b2jePX}qrQ@3(&c&pPeXIcM*6_IdZ(>sjx5p0(b!h)6mxY;>@HXiy-L zgygSyT6m7|HV+JLVopRV14Dd$Nn%wl(8n_jX||LtBa>~owntl}^zrq^ zfwn=xqXT>ghDj~V%o-b8m{~N%PQS3QU@KG8kt0W%jPUpI4GQoaY7!JO$W;C-CVpXo z0Z0=R9OfT1G}KD!?dj#|ZEY?M@f~R0SnA{J6%gb-#M<1-%*ssaIn*;?bf~Yj*{H@= zW~0o_8%qOyt^IsQNkfNw;aziSaOh}!75_a#e5}n)%a^7ZkMpV1uO@Cfl7 zI>^`ByqVP7FC-|?(*s{MmxhJ-1_bzr;+^FvOCRqryzw3uXl(`)JblIl4fVCQFmGgT zE*bL%p5p5Xh6Q^B4ICKi8)j{6Ar12j!8dTCrqY0*pdp@q_~h|@r-d{$ zz~39jeBWUv9U3D3h_`>BXPDd&|Dj>NApxF9g#BIt!$Ukrdw2&02789#vp4c6EX31) zC{BTdA)fLy14BFmeM6C3ULL`t@!sFZ8aKdKUjCk;@*nXJ@f9y@q_6)V zzc4Qx3kvof>MRW8tu30GeS7N>=sy%6rJ>%wLw&u6hgml6EgGAcNr%ZV#@fuJ8QuoVPb|KA zjb6LBujVJHCd+er~9!hycgQPfGS`u+yFmV)pn6?K-=)VwNc-b_O`hrA!opFHb>$&&x8zuVHh_TSgL@I`ak`y(>h&6KnIK79$lCVM{W)oce% zquuq#*xDyV26<*&tMB_xf6TQqJG4zl&a%n*q%>cc?2F_$6cUtB!Zx8tm;| zX?^PRD{Tj?81ryex^BWc<)$htrWw+^vwqYqLcHStJHL$kE+lw{+5LUr`z2GlX@p-( ztJ)_g;=Zq_*0ayGELp59HgR!W)~Qe7ZDd1E{V7bGxS-FvTOD5`2y=Do6k{Z>__o&s-d;f@_J~EfLM^@&VCg-WlG^p3H%J223q{JQbioTbv zywx(qQni862b%$SROv~Y_ZYF_MpKG1YN{IJEsb!OnyV<=Nz^8uXq59f{AP~q^{d5? z`{jnmo|0v3d9b)jt7U8FHD3xSG}?43%XwnmMB3x{x^sP9)zl4t8va@DL|t2KcSdI= z$T!SZ2Hj|4?R*()Fg~(^9M}_R8yuC}nLy#U`@apyPm0xa_ZU*(O=fKFik1u4i4d zx>Lb$dB3fnfBy!flzY88IE{Bo*mczH&C0;7o0IStGR+Y1z_xt%^+5MImdf)}Ov-vF z?=2rt&(bA$dmXpgH`QQd=mW|jdG>%}-9->;di~O9D zZ2cBGs|3k@bilThZV}x}=-bwqN`g<%@GKKdrm+K)|P^A{g~Y=d^k8 zQTAcv`5cqHBd0QD&#z^7nhJ!uGzzkcsJ6Fv-`EQaB`sPDLucPx($29~e9w!n7pG2B&kvZmA>P=l%zEd!^+sn!tov+S z#zqpcpdx$}JEjn6*d|PIP;X=;`@wm0Ko(y?h?Ust9~~bmF^dSpz6v;WbxYs%QI;vo z%zFHAUv^$%n(RjQvqJ;w%HeYCYUc+YfikxV?eccUG*r9hBNLkFg?*Z8n7uQ{+IB#4 zzDH`e-!AVMbL~_3>RhcS6Ux1|A7=Tc*Es*W%^JWabs9Bf7N#8Zm^WTS*-A%{Md*{W~pV!6cV8`JmapB7&1lPh)6W$u93UO|CMW8+@w&~Xu|Z{&B49FnDnBtwcNL#Buf=;MzK20rAbq_MGLo_*%7@#n_lF;w^&GDU1K+$*%f zV9UqAl`vRc9Hvw+ziZ^Ik-NtKHbh-s%_mrM*Z6Px6~#Pj!(AhHjlV8bL>!!?Tc~Tq z2xlHQRcsuW=o;x8^!vR@(K#CEv3SmGD;ea7i#L7!CE|xb^i*2NGB$ghD&?FQhWEm>_44ERj z#v-KSQQ)9F~Mb z%r#Y1G%N>X=>drWO9dii8NvZWrf^7>1WAlWRfvY=NGv@Pk$VaevJ4e|hD_l&ED47g z*IZOIEC*!i0SVVwAVQWQ957@Ghh#~RMCGm!4a<>OS|l=5&>1pCu0$j%#>H~Yv{=nl z9Kl;uG%N=s2jqX5F1d)fSPsb&Ac<;BAsUt=kt6ZHArWOtF)o(luml_y6AjA&$pQJ_ z0I5+}Tr7uVv5@4bD=q3oE~F44%Mev#$P_WB6yst!4ml2g(MTIKB8rNTWe5cfnNmbt zEQchA!*WQL7Lp7Vb%sn4 z#Z$$&SgzC*tJH{jrVtIwamaD_-$vRL7Z=L`Sppz2OsPOLEQchA-eS-{izeJAx&C^=nFQKfzPON^-Nzz%G*Cj$7gkJbu4U1{H9R}7O#58ZLfJ>yOP6<>b0z{YmA!V-^xTap=@@`rsXfpa_KwQ zU`%T}+w#LtyKc0*x}y4!S?gL&KI5aiW1rLP6OS$$blumoTI!W$E=mKIT<@CUSbkOg zPTHNY(pIx=x!}o`?;{>%P+DlnL$cU?YXHZ6Slpp z@^XATv$S$gWm$Q$cVA@nnug9C?qdIRq5S}h(RX`3{Ss7tqmI>@VLQDVRS*UlOi$U< z!`;rz?~fIcmFwOd@zIxd4@wxFbiyTezG0ait#ant)o(QE8j)mWl`8H;L9%esWsQ5)K2NwCkeGNP=Ty%Pvd^&xvYVf}7ykBL zR&Ld7_qXBM??2~lnc1f2mUCq+FJ6mTaRCsTKFp0&Rf=AGF+SZ zrfY+?co{UTYKu>8tW+oaDOZjxTkfq&&pCFDA~o)-jnh!m zw5^b=lw0NC_~eMAOO+y&J}Dc!YFjO@5OUgSk)4m1x~^KRa+FGx_TEWSwK36pQ8gpW zIVsPXSlQ;dR7XwfsNCyy?~665%^$h3$v)wJpNFa29}Eb&cVmBbb*oQ9sznWR_1$~m zWcG*k1Eb3Hx8J6ctZuM1CT6hx@>%xfPOa8e3$H%8Mo)FYO#R`-e#<`pwfTLIlzUb~ zEU()8XVmwaSnJB^OADMX=IsLLIw#fps%-M%zo#MO!Vfi?s?x3=$>JAUx zwp#r{a+0fS_2t#HBS5#zU52a zmMgmh+Vy|BVdg!Hh|x=X);&ME=Hu3;VP~hkX}PgQ=8K`>*TUy}{@yKRf_t@YeFiqL zpF2gR`(h7AjIqvY7m%qoW zpL@FZ@sQJ}%(KU?aql_%wr84hRF%UwCjZrNhP8^LG|#w#$>w+Aq}OL*tsUjt&uFJy zXZNn97U!oAYBTeE{7RQC@pBZ4*3>R5Rv6ngjMk0rs-rHQS}$^SmCJ|99X~vE=l(%c zHK@%F+5BhW)*tWMe(tIJrpN8{+l?>Z*yAv0Ny}GvLf@J8doeWi=Dz9OA3Z-X*|%1m zN?SM9Ixsh|Wsl3#P8?3(VE)IpjOmxp;&0%l8H-jo@rjFRaO`!55oRkZoL1W&?3gg& zkK}7hGe%umb^F7ILzhjAPN!N&Id|-;ry6NnF5Ay__^^goPM=@Up_P}J^!BcePv!;m zZSio)10z>mY0Px5?Q0yo`keO3Z+DDpueZ99Rb=7kKi$yP8uR*Y^`{9PKL52Ox8t?3 zeYcNz>2*PlimL-x8wK+NAl9g=iLqUu$2XUIZ`9*Zpv$^MTEEMaJN%F%Z3h3 zuDWH-wqEV4Rqs1}_|#V35B_rAyQTi4>*F`2eR_3vQth?V@@i_kbx&V7%~h}eL5-@7 z_H0p(I&1vwTBMzwYsUssFOT%_hhGM@RZ{AIXq2w5o@UaP{=Y47?7Xmi^G4fq`@a$z zE_yj5Wdo3%OkY3-_U1_MF`6(?zuZf&o!o@G=g zYS`LK?bR$DRZdipklun;3yjQKbkRDPmo;kisR5y7cRud4aFy=%`u-X<(qi0~bkMA4 za?U<3#6ep}bylRK?WyEb$+G4jkGMN}>q_I)oRpQNRv~VBzFAtAl-{Yi3+*%{hK2RyycaZ(z8synS>BUAr##E|1 z*Oh38*KO0rqp?;{&gGYXJ?q^qwCl@pQ;n?}I4`~O-phH>qCo8tS$A7}df7=(Uoh?N zEN46S&cAiinDNF*Z%L&LlfFYd)*4DmZ8WCk=Tm7UhCE0kXsZ3s!nU;GbQ}%FVocm{4Y;4}4FBeBY zRJo^q+or;;Mw?Eh#ptZlXwa+XRc$p(<;s+0^Sh*Z!ssk1H6NMKL@(^qRKx6@Io7rV@(EMPsonnd`pT%NEFuj1{$oj$vbLb&T9`S9 z2GoT?|8t83O0py|%M|7XV8<)7gf`X+J8Slc>zx%f2SB<-gkwjAvfp9zt}SUYEtyuE z=6Y$dZ}Y@#T;2V~)ZM1x_9gTm6-tU-V^d&Fu?VF~~y3q6xY zaU>RwL^QV)Cj;QFvDiUPB55pY3IHa{K9fdqKo$;2xWbInSLItDMlsU@a6lFgNQ}rVI2izkB!}cLf+U9ED@+T( zkytnqkyDD30dO309R4C47BejX2V~)ZgljA~832bQhvYAUBr1G`X#qGA3qvAnod&Gg zBiA1aERxOQATvhQ8@R@zmdoaV1EldQw+!iki` ztzR;CjV0o+1{>=B|;u#z_Zr(pq7A6S-(o z=vd<`lytO^^IE|&N2^Zxx`*1xZ~TsA}gh#^x%*XZYzWLwzM z*sQrC=Ir54u#`?vY(x}@kYxx544ERjM#Z>T?i#sk{B^ETAwrg+!q1Q?qH8QNE|vq5 z1M(LEvakqQhH$`;DaER#AVlSYZTrw}2_P~m6D6pq7^2B8)c4a;QN zXVNHfEN_7bS%z@HkSQFJB|#FSQ5B+LnJn~78YOp)iV?C56@G?H;W#V_hs8w0GFkSS zG)i=h1tMe_!U033a7dN}NmT9%(XdPwdM1rxsGu`sir8RKSQ?w-up}H76AjB`*=N!y zhETweDPn`6z|zfuKtLLowyA%Db>DWYpsjEm(s5l$r54H|<0QB1AGMq@tFiu;Wh_e~O9 zBbSv+`;wJ5%i-9d-8S!?79CaUso&>B#qJ9mA{1Zf_P$RA%;aiT!;)p&_$)Xj&VqxW zpaSu-OyYw{qeQl(5JSt5$70A7?mSE3JQd<)nJn^58dYozEkh_^$P^C9k^os)yeyNY zpGl(_D(VcGA~qNlBWGFDP8hRC%rk{}Stg4-lSVOA_!%-qY%sj$^HBx5&-uv-{tLv* zGFkeWG>Rb{Fl36nnlBJJ%a|)-tx@P!39$LJTcK{)iz{MAul;o>M(!GaooiHxp=GG>Gh~YB8jFma<$&aX{6&D&C?tlKAsjGd z3UduW#%ckv>7WoV%Vd86lSYZTrw~KSP~m6D6pq7^M%ok;FUw@vXVR#`V`v$|0Yj#6 zNR|Z2g7LCU7J4R)lDkI5wPhJ9{0y1Gaaa-#F~NUP@v=;oeI|`6JcgDb957@Ghh#~R zMCGm!FUw@1XVNH!3OYlkhz$ns!FJ(9O5)b}zBQ1q_)Y zHW&)V#WE&fvgWGAYD^&-mPs$fq)`kNe}+sE%ameVEXQH7-HBpwOOY$ea@WXR<9`~9 zT4-D>hh(vkWYXg>X_Q#uD@4dLBIx(x#ZTWf?*NL#Bx1b_>SE za!7JW{vt>gjF4rh_%mdR+%+n$Bg>jv!kj&v7?zS478@bU5DFMFMRbh?){$in0<5`e z4#`qO5*5Bege*hF|B{<&n7yHPxi4ZkzVf>xrwR8fxm%ChE%W^#?VNzcw)< zq52^3itgZ)&Ov71N{L66{)4RO?x}XL*a72`+{%dNU2C9`3wz z`><5MMp28RjPGg>&ZyWly6Yl4qal*Mnfgh}4^A8CJGD@cIXB*Yc6rLzqN6K`|w%HHqWHjW=*6?3v3>46DobKWv;gHVkblWNgba&-DMQN zz}ow1y?rV1lHNz0{?hV&KCXYgY1+~Lm0}h&m};}H^{J<_@Ymyp?rs`CbBw`?;V~&H zH?p!v9jH5IXHCc61_rg&Vs_``WIsJ?bG+T}wQCCM=l2ZVc->FsxC*5#zyIPok=Mo7 z@1Kh=dHa*8tZ|hpeIux4Y?YA_H7r@{Vii;uF1i#_A@_*t-8ubRxB6mr^{(vJ_*Xao zy4qScNTwCE*X4Nlv%fx%m-S3P{MW~T+P7*&t^6`0bm7%u=dSFIc^agAc5J3~r{~A) zeCNHpH*2Bqp;5ZJxAtrqsG*@Tt#|18V|I3KjgACMtEmNO4b#sYaky+{Q~k)y*HzCw zjJanLf3%-Lhl#BQ)?Iqm{#75mZLvKjCAxDHyWo1Al~T9)T;J~3qKR~22N&(%4W=Bc zdfc=8o&BA2FO4wDd>yX!L6-PMb|r7^jod9Mx%0QZZ|$wCqP(JGR@b01c1A0nw5k^| zeMb9g)%#BCQ1Qe{#WlXQuYR;Q3vzDUh z74`_$)zxd*WfmTZbH1#7)TY75&M}Mp*Zl7``3)#$(PM@@#SPPlC?nhInAbZ=KCk!W zMVV>2M>1s(N5;8-mc_>A9r|){^h1?<>bGqw+-kJxR9cMAI*kUsYF^b=vsA83SvJ2* znu`;87dn@>a@>H~OY3Ki%D80W5)-fE^C75JPO?__^`v=^waSqG$p~?R^T~;I7Rx@j zk$rwVYJTXuT-jyW^U!Cr9V)z-JmN%xx=mvY*4tLHGh8u^T8-;# zBUyqHgHW@AD;cxcBuh7q@M~#R`{cxLg~ly?*GE~VEHmrz!+qI#iD|MM+0VABs;VBl z+WCP;pv-MTyS$w-4b`ss$b=?(VV|ZNX79|gwjGd9q)1Nf_S@whW3GJ)U!AMk#OEsb!OnyV<=Nz^8u&>x-C=EX#TI}1r@VslW{hpDTnOWnC zZY^WR@5y!hSm)Cv-K4ht;=d?goO}6c-IXUVpLxG^}+@< z-f*ba$Vm2mlxlm7Qf1K{GHDb?U16vbYk9>OY}Qmb#_SREOd;}`$s*6BQ5=Vbu~;NBNyeBoO3tqp*D>Zz)WVyn z#l>KAKypCl>r(uQ9z~HKij9Z@(byc4g+mgRmtqVyMDh9A%-axxi&HHRV-z% zqUah6L}PPEa!CFRBnw7fGwFqxG>T)cFwBV!2E}Muu9+6AnHC%1%4FGR(kKqd!U0)m zEG&m)36O+qR0xJ;vd}YW6h~s=NJN=Z42$JBECGi#Z5QuzO0o@D=&a%*%gz2Cz^M9x z2QZcUrgYvpea`%8&K7$nJvpId(zfl{ z#P3B;iAD{48ZpT+&_Iw9l{XYTRC={-t8AjwrDclp@Cu%UyN9P5yh0CVBKbcf7?qUM5RDlSYZ7 z2l7QU9OXDH3Wo}#^LbcgJa>r$@(ri^naQiZ4K@E3(clNF5G3P{vE+%YzKzaTSC|-v zyGHIB^I`JmqdycQ8j`tdEZQ~Z8=cRasN6OFf_WYPag9YqG;p!DFtL{N!;;q0E-ZS1 zNrJ|tQ6g*1H)5Z&Mn2oB`<$N}#gmXPdV$aFSgI+H|8b44zjZL>XN|VJty*YXmAl50 zU1Op`^a6w~KE$=uLtN2MZk&JiG~P(%jntoWjrrG-a=lNEULS5W7mcljuzp-W&lT?PKw2k$!oKN9*#*f{SdEi?AOtUT0Wvm~#xJ=n^GV7?LeytPFZKW-u z^rZ{Ds^0X_?&#dMqw|vIsm&)@Sv>FW(kWp^#F$wbs=LQ*_Zn>P8ydfVyV}cH+arTF z#wUeT+2Cckpjx+;R{C3;xmeHKE(vs8;psMT1-WIYEPT-WwMy;ZqTdXR?0i(E{eHWx zF{gHRUf}4k)h@dHiA{}T^{aQU?xMT7Zc>XbE!;y}IMSQteKGp$u~!|&a?_Ldib%u-kFcBT~k92Tr*t%IxNGXXUh$Ps=2?cV>$W4{TR#|_7lwv(jB^@h(DmP6?MlrC^}lY_{xLSc>81E|@$rutwY*f9 zirgsnN|6g2=jtUBHBSi)3K~H~0f8g@eDM0iL+MW<12qyzzWwva_W`>VKHL10vaol` zMAh`dLOh4ypnKR5@i(!)#@~-`XT$F*J`{eZu-}zLz?^&H7j=Do!b0(`u5UnSn7Bu@ zRDNEVAf8KeX`qk4Cyq%2eH5>~6_KPDxt$QdY3%0{5c2IDG{WD<_xpSKYY%)5a`YVP z6W}XPqv;svKXjn{&4@tpq(7Ri>+|(kU7rwNc^dgceq42UNPzU~drg(lFs}f-PYw+W z{eDB_X|{Tf@HWS*{PE*C#T}9eT(%?L%AfLwP|X?Xn^~AxSelrdnMniuy+&CyYx4D= z_`jx1hSz3D)mHGKdb0aeYY<*HX4ovV-l{GaitsB+)T&B#<9Py=^?R0iRwp_4!w317 z)q*`kgYhc2L;lqC3Jdv`$u(8*S^lUQ8YbQV`Fo@h8X-r&?GKgT82LlKL;n5GSbxRO z`|tFE(bxUBQR49m-(J6emVcCgPdp!9`SGv$!Sm;9KFEs)FCQEqyngtK171IV%?DmT ze$5B`|36nh0)*f5otML}`TjjW{*T@Vw3Ec&HAK78Tm0pJ+F#$>Vc%cnX-^>Cm~ZLi zW~2S4q>DE-@YMX_`5WF>=b!)16P_QuJp8|mhq}PBoXE0{bGe;A=5ryRl@Er7c?TgK z9wMhSBPxT>pH7OjHNQeoH2*_moH* z>{@3mk?u_hBYH%Js7D*_CNg?YWMV^PI)TXi527XpL{0lbT-ico`JAYw9Z{qlgNfXLwy2)i?p(UDvrk1F(%9z^|? z5_vo!^0FrK9!BJgV}nq!`}ZOmJOerojs-ap1%qRUz?Lu^8{tDVawgGe*fLg&XgpR< z3{NGR2wNugA&Oc=H02>tv<}g<;Y2ge68#Q!U{*Jx*|Uh^9}~^3O*DTf(Si*`i{T%Z zdJrYV5G{L6v;uj*3ch0XDx#!{L>o|kHa8&J4zXY-@@zM3*|UadAJQIZMsx_*bOhIX z6b*voxZV@Erjxkd)5y~^ICky<(FI)Z#lb{>;MnC{qN@f(*CrEP-%E6}Cedv#qC3lo z?&T0YXiW5QJkcZM>7SK}p5l6+Eg*V<>wN_~vT?m{b`ZV8^}e?!`he^G1lzvo5ar<- zWV=X^R3$;lnuIc|NGSK3gz_dNR2)Tu>JbvER3t&oi3E*!5~|)Hp}IZ^HOG)pYcmOT zaqKr+613)$AWbEqK91>4AwhpD2@NWf&=AKOEhoYFED2@}NHBLNq47Zynw2G?xjhLj z_K{%qkp%0uB($DLg3T2Y+BPJ?zCQ`=wv*8Q6$u?2Na#44gihy4aH&9o>i`nE9VVgs zpCt5jA)(h;68dD2;112E|6mdZ>?XnUEeSrYN${Oa!k|A$@UKF`klrK&EF)p)a}tKx zkO0j_2*a@vVWz8;bA`#9!(`7>k$c0 zElGHW`}+bmzC!ho-Hn7dxW8{<<6q_^e1QF*PLS{!Hs&EeWHBTW?vg~QDoM%=B1yRm zBvJW9l8U`Zq8dq(%1=n5R*fVYp(N2fM3U;tB&pejB(-Leq|SAc{H8?`tsx|lt|N&~ z4oUP}NTNTPB!(FzX;_IQjr>VsoJbN=9J6pmee|dpkyL_;0*`zmsQ^&oI^_R;cqn|f z`QZ@WmO>;|>sPc$dLV+z^(<5*4GkKOy5gTlH+=T_MXywOV9`l(Xyp(9D)w^TV?o6zP0TYgC2e z?~Fn^RHHNn&uMs~>ZLh&PXG3PE`KlP3C{--;D#k|bwU=N`%o?t@EncjL_FnX=X;sq z<>xDoST8e6aL?p;XWh>4<%-+M?-#>mK)4Ppy#&?s`+)+&&ngW81)M}w7OjACV~JFz zf(nABRRC2`g{)P{26Qlts0!*+wbKayDMM7%6itA>=(k4t>ZogLWMZNcIQiNgKo3Wt zi+LU>;uTQDJTwdHfhPKbCe9|RzYQ%cv@P{<4uck;ixET(khbADqDB>njHRHB-9Z}{ z6PY2cIqLGp>Y$G;K_7$ASXl@PiL@=yzOZTp8rcIh5_P-vPEbjtwfT+6wms1BD!J1v)wkbaVyLsAFjElm{hkN;J+5v=nwtz&XNi z!;R?@MI!B_V9-<8HTeXZMIS&>+YrSd?KIRw(|3WY-X)q@l_=I4a75bKpqX(OL0Lbc zDm5XR*9){2X%`$NTKEjqwHncqrl7B(aG6M(c#>#&Sx{IrqLp1hV{vV(k#@~}P+4uF zb8A`$)LE%_oK+O zW5~1P<3V$`5vANDI)!{cZAX-fJUg?1= zXz?h};*CVl(*X^neTBSy?GJh!4|;rxC?^{f8EN15A^Ix{GUAVYACsV?25Q}k1YOW) zy+xqcpk@XSL9vZMv3o+@83~%b3pD#K2_`a7?RKErAtYGDfo{XDCOAj4+MwK?Bv>v4 z?M@@1WhD|?)d%&)_gg2B&}Jv-_frz=G(o{Zo$c{`hb5rlNb6XJ1SeBaaZu=vzmwn$ zirEQiU8E#*=>SR|Mncy)pyiiHa6{T2dZ6aMpysh8^xgt`{sQzIY5UoLqDPS6F%L8y zwA4$51n+vF>Y(4gi%A%`4Rrkx=sN5g+yayxG&}%wKJXj~s1k(WhM?}9}VzVAo7s+w5kb_sJy0BcJEs{>_7p^P7O?`+??fCSeiwFaAWrQeDt} z(DcMe5|-h&mm~jIRwH3m8_<5x^)*XLSbGZ8A8FT{q1oC8^goJ(O{*XQJRo5!(r#-C z8DKbMfJ72@9)T2)3n>6;_jW@VYk3t%F2Wg-& z2`NsH2c|$CI7!0kTO^!OBjKzSWC9<^1j|Xda28U*UyurnNVwz%xgZ>J0rKxE^7>jJ z3D0V3djk_`&Y>0*GQX<>wmMBgq%Z=6_EGu>p)sS+7Gz?kDDPcTqofR@;(=NorknC z(0@vW%m4{M$$%uvu8?7b!NKgRkHCzedie&g-H`HrTFkZhOK_US)kem@05IN-E`u-4UG#5GYj{^G%kwb}; z6-18o$dW~l^hn$XE^=@}hQ<>orpN=H5C0ttT;wP=4~2Q~iXaGthC3JYkOy-)?ux5> zixN4M10iyJhRE>(0tcE|~b(0g(d)t`Z1q6%ar)97(+wM2$(!fELakz+nYjy(`L9z)>J zg1`ZL+l++Bu^l4EB?uh24tt!#p$|lkIEWlb>xk>?P!|FR&g(oJBF83(97x+m1p-HX zqOLe^w^Lt$oMUMo1de9tqet51 z_~MFWh#WZY>RJ#uT#42qZ4v~5^$&>TC!+Hc zA#$KRq+Nx;p#g!TE&BHHdsmh~Ko<4&AaH=_8;*v^u^S=>NN6LFMPnNX93c9pb0Bj3 z0f7S~w6OsMj=m%`9RZPJ3q%eO=oSqyt^nk>5IN>Tcz@blqC(i2?50L|e*9U0_V&5QJ68vypf9xBa3V{P@ z1F>%?#GN3VH+UCBj)xF9kT$Fx3ByN1!W`8*f* zWZpW6948=hApaKOo-D@xC9x1Wkf#ZEAaK-xz|kCI8j$}h*F)qu1Ciq+1P%)b99>CR zHw_|33Pg@a5I8g;aNrzU0wHp&hRA`mfR(V*l!RS4@9t=b9LW$lkam9^2pkXr4&uCr zFs9=0VF(;ZdldU19ty{C-V@lDk^zAOX-{Jx#6#f>&U-cmBF9?@97vnig9M0&!XG&A zrF#%KszBiA0=69tkptJ1fqQzRH<}pu?OVvB+xH=Gs6pUB9^DUt$bsL^L>@i-0)fK_ zeT(?+pT9%oNP@_b34sIG{{q+gatK5Y?0bD2B1aws4(xl|iG+7kAabmM$br22SPKFN zuK%+iM2_VUIgUc)_y}2`2?UPrB#}ToP&x>a;}!%CO$ZziAXI`NawI_HfB;ePF9;l_ z5IB&w$~1@^`yg`Mfxw{wfy0<2)sPnBv?Mi>A#&in7^@|z<4BUaNc$W1)w>CS1Lwt< zEs2gdL=K23x+frVKm;*>h+^28Bn`$xbRTP!RV1 X=Hn@TS&*kdY}Y`