From 97daff632916d72a3b7043c50e012dbf8896f90d Mon Sep 17 00:00:00 2001 From: kleeder <5kleeman@informatik.uni-hamburg.de> Date: Mon, 7 Feb 2022 15:03:18 +0100 Subject: [PATCH] Massive Refactoring; added Axx support; sample-mapping via instrument, not octave; Volume is now mapped from 0-64 values, non-set-volume doesn't map to None anymore --- README.md | 54 +++++++- it2fss.py | 210 +++++++++++++++++++++++++++++++ it2fss041.py | 261 --------------------------------------- pytrax/__init__.py | 0 pytrax/impulsetracker.py | 216 ++++++++++++++++++++++++++++++++ test.it.fss => test.fss | 26 ++-- test.it | Bin 23536 -> 23618 bytes 7 files changed, 493 insertions(+), 274 deletions(-) create mode 100644 it2fss.py delete mode 100644 it2fss041.py create mode 100644 pytrax/__init__.py create mode 100644 pytrax/impulsetracker.py rename test.it.fss => test.fss (51%) diff --git a/README.md b/README.md index 49ceb82..a7ef874 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,53 @@ -# it2fss3 +# it2fss -Improved version of: https://gist.github.com/jangler/9565970 \ No newline at end of file +Converter which takes single-channel ImpulseTracker modules as input and outputs a fSound .fss file, for use with fsound.exe. +fSound is available [here](https://kleeder.de/files/botbFiles.php). This converter aims for the version called "fsound.zip". +The [Pytrax/impulsetracker-parser library](https://github.com/ramen/pytrax) is used and +slightly modified to work with Python 3. + +Improved version of: https://gist.github.com/jangler/9565970 + +------- + +## Features +- Squarewaves of any volume and pitch are supported. +- White Noise of any volume is supported. +- Tempo Changes are supported. (Txx with values bigger <= 20 and Axx). +- Kick and Snare Samples are supported. +- Only use an instrument setting, volume or effect along with a note, otherwise it will throw an error. + + +------- + +## Installation +- download/clone repo +- make sure Python 3 is installed +- write your song using the test.it (follow the limits listed below) +- convert your song with + ```bash + python it2fss.py test.it + ``` + +------- + +## Limitations +- Square Waves can be produced between C-2 and B-8 (C-1 to B-7 in fSound), using Instrument 1. +- White Noise is always the same pitch, using Instrument 2. +- Kick uses Instrument 3 and Snare uses Instrument 4. +- Make sure to set an instrument for every note you put in. + + +- Square and Noise have volume control. v00 is lowest, v64 is highest. +- The values get mapped to the 16 available sound values in fSound (0-F). +- Non-set volume gets mapped to f (loudest). +------- +## Version history + +* 0.5: Massive Refactoring; added Axx support; sample-mapping via instrument, not octave; + Volume is now mapped from 0-64 values, non-set-volume doesn't map to None anymore +* 0.4.1: Fixed a bug where consecutive drum notes would be combined into one +* 0.4: Added Tempo Change Support +* 0.3: Added Sample and Volume Support +* 0.2: Fixed a bug that occurred when translating a single IT note into + multiple FSS notes. +* 0.1: Initial creation of program. \ No newline at end of file diff --git a/it2fss.py b/it2fss.py new file mode 100644 index 0000000..9ab0636 --- /dev/null +++ b/it2fss.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python + +# it2fss.py, version 0.5 + +from sys import argv, stderr, version_info +from math import floor, log2 +from pytrax import impulsetracker + + +def die(msg): + if isinstance(msg, BaseException): + msg = str(msg) + stderr.write(str(msg) + '\n') + exit(1) + + +if version_info.major < 3: + die('python3 only!') + + +if len(argv) == 2: + MODULE = argv[1] +else: + die('Usage: {} MODULE'.format(argv[0])) + + +NOTE_NAMES = ['a', 'A', 'b', 'c', 'C', 'd', 'D', 'e', 'f', 'F', 'g', 'G'] +VALUE_NAMES = ['f', '8', '4', '2', '1'] + + +# Calculates and returns the fSound Speed based on the tempo and speed of an ImpulseTracker Module. +def get_fsound_tempo(tempo: int, speed: int) -> int: + return 2500 // tempo * speed + + +# Sets current row values. +# input: an .it row +# output: cur_item (absolute note value. if not set, the script stops) +# cur_instr (instrument value. if not set, the script stops (unless its a note cut) +# cur_vol (volume value. if not set, it defaults to 64) +# cur_cmd (command + value. if not set, it defaults to None) +def get_row_info(row): + cur_item = None + cur_instr = None + cur_vol = None + cur_cmd = None + error_msg = "ERROR: There are rows with content but no note in your song." + + try: + cur_item = row[0]['note'] + # check if the current note is a note cut (254) or not + if cur_item != 254: + try: + cur_instr = row[0]['instrument'] + except: + error_msg = "ERROR: There are notes in your song with no instrument assigned." + die(error_msg) + else: + cur_instr = None + try: + cur_vol = row[0]['volpan'] + except: + cur_vol = 64 + try: + cur_cmd = row[0]['command'] + except: + cur_cmd = None + except: + die(error_msg) + + return cur_item, cur_instr, cur_vol, cur_cmd + + +# returns a .fss string with linebreaks for one row of the .it file +# inputs: note (absolute IT note) +# rows (the amount of empty rows until the next note happens (or end of song)) +# vol (the volume of the note) +# instr (the instrument used for the note) +# speed (None if there is no new speed to set, new speed value otherwise) +# output: string with .fss content +def note_format(note, rows, vol, instr, speed): + # 64 values get mapped down to 16 + vol = round(vol/4) + # dec values need to get converted to hex + if vol == 10: + vol = "a" + elif vol == 11: + vol = "b" + elif vol == 12: + vol = "c" + elif vol == 13: + vol = "d" + elif vol == 14: + vol = "e" + elif vol >= 15: + vol = "f" + + lengths = [] + while rows > 0: + power = min(4, floor(log2(rows))) + lengths.append(VALUE_NAMES[power]) + rows -= 2 ** power + + strings = [] + + for length in lengths: + if speed is not None: + if speed > 9: + if int(str(speed)[1]) in [1, 2, 4, 8]: + print("Your song uses the speed value t{}. Keep in mind, that speed values with 1, 2, 4 or 8 at the 2nd position will result in pauses in your song.".format(speed)) + strings.append('t{}'.format(speed)) + if note == 254: + strings.append('r-' + length) + else: + fs_note = note - 9 + octave = fs_note // 12 + if instr == 1: + if not 1 <= octave <= 7: + die("ERROR: Your module file uses octaves below 2 or above 8.") + name = NOTE_NAMES[fs_note % 12] + strings.append(name + str(octave) + length + str(vol)) + elif instr == 2: + strings.append('x-' + length + str(vol)) + # for kick and snare, only trigger them once, then set note to r-x + # speed has to be set to None, to avoid multiple trigger of the same speed value + elif instr == 3: + strings.append('K-' + length) + speed = None + note = 254 + elif instr == 4: + strings.append('S-' + length) + speed = None + note = 254 + else: + die("ERROR: Your module file uses instruments higher than 4.") + + if strings: + return '\n'.join(strings) + '\n' + return '' + + +# calculates a new speed given either a Txx or a Axx effect with value +# returns the tempo and speed too, because those changed values will get reused later on +def calc_new_speed(cur_cmd, tempo: int, speed: int): + new_speed = None + if cur_cmd is not None: + if cur_cmd.startswith("T"): + tempo = int(cur_cmd[1:], 16) + new_speed = get_fsound_tempo(tempo, speed) + elif cur_cmd.startswith("A"): + speed = int(cur_cmd[1:]) + new_speed = get_fsound_tempo(tempo, speed) + return tempo, speed, new_speed + + +# converts an .it module into a .fss file +def convert(module, filename): + outfile = None + try: + outfile = open(filename, 'w') + print(".fss file created.") + except BaseException as ex: + die(ex) + + length = 0 + tempo = module['inittempo'] + speed = module['initspeed'] + + cur_item = None + cur_instr = None + cur_vol = None + cur_cmd = None + + outfile.write('{}\n\n'.format(get_fsound_tempo(tempo, speed))) + outfile.write('> generated by it2fss.py Ver 0.5\n\n') + print("Header written.") + + print("Converting patterns.") + for order in (x for x in module['orders']): + # +++ Patterns + if order == 254: + pass + # --- Pattern (End of Song, no matter if there are other patterns after) + elif order == 255: + tempo, speed, new_speed = calc_new_speed(cur_cmd, tempo, speed) + outfile.write(note_format(cur_item, length, cur_vol, cur_instr, new_speed)) + break + else: + pattern = module['patterns'][order] + pattern_comment_check = True + for row in pattern[0]: + if len(row) > 0: + if length == 0: + cur_item, cur_instr, cur_vol, cur_cmd = get_row_info(row) + else: + tempo, speed, new_speed = calc_new_speed(cur_cmd, tempo, speed) + outfile.write(note_format(cur_item, length, cur_vol, cur_instr, new_speed)) + length = 0 + cur_item, cur_instr, cur_vol, cur_cmd = get_row_info(row) + if pattern_comment_check: + outfile.write('> pattern {}\n'.format(order)) + pattern_comment_check = False + length += 1 + + outfile.close() + print("File sucessfully converted.") + + +module = impulsetracker.parse_file(MODULE, with_patterns=True) +convert(module, MODULE[:-3] + '.fss') diff --git a/it2fss041.py b/it2fss041.py deleted file mode 100644 index 1175c05..0000000 --- a/it2fss041.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env python - -""" -it2fss.py, version 0.4.1 ----------------------- - -Python 3 only. - -This script converts single-channel Impulse Tracker module into a FSS text file -for use with fsound.exe. Tempo Effect command is supported. -Kick and Snare Samples are supported! - -To make white noise, use note value C-0 or everything else except C-1 to B-9. -Kick Sample is triggered with F-1 and Snare with F-9. - -Make sure to use the Volume Column for every note. Volumes 0 to 15 are mapped -to 0 to F and everything above 15 is also mapped to F. - -White Noise and Square Wave need to have a specific volume. That means, if you -leave the column empty for notes in this octaves, they are converted to "None" -and might cause errors. -Kick and Snare Samples don't have volume control, so feel free to leave the -volume column empty on that rows, it won't have any effects. - -The only supported effect is Txx. Every other effect will be ignored! -Tempo Slides are not supported, all values <20 will just be interpreted as BPM<32. -Only use the effect along with a note, otherwise it will be ignored. - -Version history ---------------- - -* 0.4.1: Fixed a bug where consecutive drum notes would be combined into one -* 0.4: Added Tempo Change Support -* 0.3: Added Sample and Volume Support -* 0.2: Fixed a bug that occurred when translating a single IT note into - multiple FSS notes. -* 0.1: Initial creation of program. -""" - -from sys import argv, stderr, version_info - -def die(msg): - if isinstance(msg, BaseException): - msg = str(msg) - stderr.write(str(msg) + '\n') - exit(1) - -if version_info.major < 3: - die('python3 only!') - -from collections import namedtuple -from math import floor, log2 -from struct import unpack_from - -if len(argv) == 2: - MODULE = argv[1] -else: - die('Usage: {} MODULE'.format(argv[0])) - -Module = namedtuple('Module', ('speed', 'tempo', 'orders', 'patterns', 'patternsVol', 'patternsCmdVal')) - -NOTE_NAMES = ['a', 'A', 'b', 'c', 'C', 'd', 'D', 'e', 'f', 'F', 'g', 'G'] -VALUE_NAMES = ['f', '8', '4', '2', '1'] - -def note_format(note, rows, vol, cmdVal, speed): - if vol != None: - if vol == 10: - vol = "a" - elif vol == 11: - vol = "b" - elif vol == 12: - vol = "c" - elif vol == 13: - vol = "d" - elif vol == 14: - vol = "e" - elif vol >= 15: - vol = "f" - - lengths = [] - while rows > 0: - power = min(4, floor(log2(rows))) - lengths.append(VALUE_NAMES[power]) - rows -= 2 ** power - - strings = [] - - for length in lengths: - if cmdVal != None: - temp = '{}\n\n'.format(2500 // cmdVal * speed) - strings.append('t' + temp) - if note is not None: - if note < 120: - fs_note = note - 9 - octave = fs_note // 12 - if 1 <= octave <= 7: - name = NOTE_NAMES[fs_note % 12] - strings.append(name + str(octave) + length + str(vol)) - elif octave == 0: - strings.append('K-' + length) - elif octave == 8: - strings.append('S-' + length) - else: - strings.append('x-' + length + str(vol)) - else: - strings.append('r-' + length) - else: - strings.append('r-' + length) - - if strings: - return '\n'.join(strings) + '\n' - return '' - -def read_orders(data): - ordnum = unpack_from('H', data, 0x20)[0] - return unpack_from('B' * ordnum, data, 0xC0) - -def pattern_offsets(data): - ordnum, insnum, smpnum, patnum = unpack_from('HHHH', data, 0x20) - offset = 0xC0 + ordnum + insnum * 4 + smpnum * 4 - return unpack_from('I' * patnum, data, offset) - -def read_pattern(data, offset): - _, rows = unpack_from('HH', data, offset) - offset += 8 - - prev_maskvar, prev_note, prev_ins = ([0] * 64 for i in range(3)) - prev_vol, prev_cmd, prev_cmdval = ([0] * 64 for i in range(3)) - items = [[None for y in range(rows)] for x in range(4)] - itemsVol = [[None for y in range(rows)] for x in range(4)] - itemsCmdVal = [[None for y in range(rows)] for x in range(4)] - - for row in range(rows): - while True: - channelvariable = unpack_from('B', data, offset)[0] - offset += 1 - if channelvariable == 0: - break # end of row - channel = (channelvariable - 1) & 63 - if channelvariable & 128: - maskvar = unpack_from('B', data, offset)[0] - offset += 1 - else: - maskvar = prev_maskvar[channel] - prev_maskvar[channel] = maskvar - - if maskvar & 1: - note = unpack_from('B', data, offset)[0] - prev_note[channel] = note - offset += 1 - else: - note = None - - if maskvar & 2: - ins = unpack_from('B', data, offset)[0] - prev_ins[channel] = ins - offset += 1 - else: - ins = None - - if maskvar & 4: - vol = unpack_from('B', data, offset)[0] - prev_vol[channel] = vol - offset += 1 - else: - vol = None - - if maskvar & 8: - cmd, cmdval = unpack_from('BB', data, offset) - prev_cmd[channel], prev_cmdval[channel] = cmd, cmdval - offset += 2 - else: - cmd, cmdval = None, None - - if maskvar & 16: - note = prev_note[channel] - if maskvar & 32: - ins = prev_ins[channel] - if maskvar & 64: - vol = prev_vol[channel] - if maskvar & 128: - cmd = prev_cmd[channel] - cmdval = prev_cmdval[channel] - - if channel < 4: - items[channel][row] = note - itemsVol[channel][row] = vol - if cmd == 20: - itemsCmdVal[channel][row] = cmdval - else: - itemsCmdVal[channel][row] = None - - return items, itemsVol, itemsCmdVal - -def read_patterns(data): - offsets = pattern_offsets(data) - patterns = [] - patternsVol = [] - patternsCmdVal = [] - for offset in offsets: - pattern, patternVol, patternCmdVal = read_pattern(data, offset) - patterns.append(pattern) - patternsVol.append(patternVol) - patternsCmdVal.append(patternCmdVal) - return tuple(patterns), tuple(patternsVol), tuple(patternsCmdVal) - -def read_module(filename): - try: - with open(filename, 'rb') as f: - data = f.read() - except BaseException as ex: - die(ex) - - if data[:4].decode('ascii') != 'IMPM': - die("Invalid IT module: '{}'".format(filename)) - - speed, tempo = unpack_from('BB', data, 0x32) - orders = read_orders(data) - patterns, patternsVol, patternsCmdVal = read_patterns(data) - return Module(speed, tempo, orders, patterns, patternsVol, patternsCmdVal) - -def convert(module, filename): - try: - outfile = open(filename, 'w') - except BaseException as ex: - die(ex) - - outfile.write('{}\n\n'.format(2500 // module.tempo * module.speed)) - outfile.write('> generated by it2fss.py Ver 0.4.1\n\n') - - item = 255 - length = 0 - vol = 0 - cmdVal = module.tempo - - for order in (x for x in module.orders if x != 255): - pattern = module.patterns[order] - patternVol = module.patternsVol[order] - patternCmdVal = module.patternsCmdVal[order] - outfile.write('> pattern {}\n'.format(order)) - for row in range(len(pattern[0])): - cur_item = pattern[0][row] - cur_vol = patternVol[0][row] - cur_cmdVal = patternCmdVal[0][row] - if cur_item is not None and (cur_item != item or cur_vol != vol or cur_item <= 17 or cur_item >= 113): - outfile.write(note_format(item, length, vol, cmdVal, module.speed)) - - length = 0 - item = cur_item - vol = cur_vol - cmdVal = cur_cmdVal - length += 1 - outfile.write('\n') - - if item: - outfile.write(note_format(item, length, vol, cmdVal, module.tempo)) - - outfile.close() - -module = read_module(MODULE) -convert(module, MODULE + '.fss') diff --git a/pytrax/__init__.py b/pytrax/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytrax/impulsetracker.py b/pytrax/impulsetracker.py new file mode 100644 index 0000000..20410be --- /dev/null +++ b/pytrax/impulsetracker.py @@ -0,0 +1,216 @@ +# IT Structure +# ============ + +import struct + +IT_HEADER = '<4x26s2x8H5BxHL4x128B' +IT_HEADER_INS = '<4x12sx3BH6BHBx26s6x120H' +IT_HEADER_SMP = '<4x12sx3B26s2B7L4B' +IT_HEADER_PAT = '<2H4x' + +def parse_file(filename, + with_instruments=False, + with_samples=False, + with_patterns=False): + with open(filename, 'rb') as file: + return parse(file, + with_instruments=with_instruments, + with_samples=with_samples, + with_patterns=with_patterns) + +def parse(file, + with_instruments=False, + with_samples=False, + with_patterns=False): + + data = struct.unpack(IT_HEADER, file.read(struct.calcsize(IT_HEADER))) + + info = { + 'songname': data[0][:data[0].find(b'\0')], + 'ordnum': data[1], + 'insnum': data[2], + 'smpnum': data[3], + 'patnum': data[4], + 'version': _get_version(data[5]), + 'compat': _get_version(data[6]), + 'flags': data[7], + 'special': data[8], + 'globvol': data[9], + 'mixvol': data[10], + 'initspeed': data[11], + 'inittempo': data[12], + 'pansep': data[13], + 'pantable': data[16:80], + 'voltable': data[80:144], + 'orders': struct.unpack('<%dB' % data[1], file.read(data[1])), + } + + insoffs = struct.unpack('<%dL' % data[2], file.read(data[2] * 4)) + smpoffs = struct.unpack('<%dL' % data[3], file.read(data[3] * 4)) + patoffs = struct.unpack('<%dL' % data[4], file.read(data[4] * 4)) + + info['message'] = '' + if data[8] & 0x01: + file.seek(data[15]) + info['message'] = file.read(data[14] - 1).replace('\r', '\n') + + if with_instruments: info['instruments'] = _get_instruments(file, insoffs) + if with_samples: info['samples'] = _get_samples(file, smpoffs) + if with_patterns: info['patterns'] = _get_patterns(file, patoffs) + + # add sample data to samples + if not ('instruments' in info and info["instruments"]) and with_samples: + _load_sample_data(file, info['samples']) + + return info + +def _get_version(byte): + ver = '%x' % byte + return '%s.%s' % (ver[0], ver[1:]) + +def _get_instruments(file, offs): + result = [] + + for off in offs: + file.seek(off) + data = struct.unpack(IT_HEADER_INS, file.read(struct.calcsize(IT_HEADER_INS))) + + result.append({ + 'filename': data[0][:data[0].find('\0')], + 'nna': data[1], + 'dct': data[2], + 'dca': data[3], + 'fadeout': data[4], + 'ppsep': data[5], + 'ppcenter': data[6], + 'globvol': data[7], + 'chanpan': data[8], + 'rvolvar': data[9], + 'rpanvar': data[10], + 'trkvers': data[11], + 'numsmp': data[12], + 'name': data[13].replace('\0', ' ').rstrip(), + 'smptable': map(lambda x: ((x & 0xff00) >> 8, x & 0x00ff), data[-120:]), + }) + + return result + +def _get_samples(file, offs): + result = [] + + for off in offs: + file.seek(off) + data = struct.unpack(IT_HEADER_SMP, file.read(struct.calcsize(IT_HEADER_SMP))) + + result.append({ + 'filename': data[0][:data[0].find(b'\0')], + 'globvol': data[1], + 'flags': data[2], + 'volume': data[3], + 'name': data[4].replace(b'\0', b' ').rstrip(), + 'convert': data[5], + 'panning': data[6], + 'length': data[7], + 'loopbeg': data[8], + 'loopend': data[9], + 'c5spd': data[10], + 'sustbeg': data[11], + 'sustend': data[12], + 'offset': data[13], + 'vibspeed': data[14], + 'vibdepth': data[15], + 'vibrate': data[16], + 'vibwave': data[17], + }) + + return result + +def _load_sample_data(file, samples): + for s in samples: + file.seek(s['offset']) + s['bytes'] = ((s["flags"] | 0x2) >> 1) + 1 + s['sampledata'] = file.read(s['length'] * s["bytes"]) + return samples + +def _get_patterns(file, offs): + result = [] + + for off in offs: + file.seek(off) + data = struct.unpack(IT_HEADER_PAT, file.read(struct.calcsize(IT_HEADER_PAT))) + result.append((_get_pattern_data(file, data[0], data[1]), data[1])) + + return result + +def _get_pattern_data(file, length, numrows): + result = [] + + lastmask = {} + lastnote = {} + lastinstrument = {} + lastvolpan = {} + lastcommand = {} + + for i in range(numrows): + row = [] + + while length: + field = {} + + channelvar = ord(file.read(1)) + length -= 1 + + if channelvar == 0: + # End of row. + result.append(row) + break + + field['channel'] = channel = (channelvar - 1) & 63 + + if channelvar & 128: + mask = lastmask[channel] = ord(file.read(1)) + length -= 1 + else: + mask = lastmask.get(channel, 0) + + if mask & 1: + field['note'] = lastnote[channel] = ord(file.read(1)) + field['notestr'] = note_to_string(field['note']) + length -= 1 + + if mask & 2: + field['instrument'] = lastinstrument[channel] = ord(file.read(1)) + length -= 1 + + if mask & 4: + field['volpan'] = lastvolpan[channel] = ord(file.read(1)) + length -= 1 + + if mask & 8: + command = chr(ord('@') + ord(file.read(1))) + value = ord(file.read(1)) + field['command'] = lastcommand[channel] = '%s%02X' % (command, value) + length -= 2 + + if mask & 16: + field['note'] = lastnote[channel] + field['notestr'] = note_to_string(field['note']) + + if mask & 32: + field['instrument'] = lastinstrument[channel] + + if mask & 64: + field['volpan'] = lastvolpan[channel] + + if mask & 128: + field['command'] = lastcommand[channel] + + row.append(field) + + return result + +NOTE_KEYS = ['C-', 'C#', 'D-', 'D#', 'E-', 'F-', 'F#', 'G-', 'G#', 'A-', 'A#', 'B-'] +def note_to_string(note): + if note == 254: return '^^^' # note cut + if note == 255: return '===' # note off + return '%s%d' % (NOTE_KEYS[note % 12], note / 12) diff --git a/test.it.fss b/test.fss similarity index 51% rename from test.it.fss rename to test.fss index 580248b..d980a41 100644 --- a/test.it.fss +++ b/test.fss @@ -1,32 +1,36 @@ 60 -> generated by it2fss.py Ver 0.4 +> generated by it2fss.py Ver 0.5 > pattern 0 t63 - - x-4a x-46 x-44 x-42 -t93 - - +t90 d34a d246 d34a - -> pattern 1 d246 +> pattern 2 +t30 +x-4a +x-46 +x-44 +x-42 +t60 +d34a +d246 +t90 +d34a +d246 +> pattern 1 t39 - - K-2 x-88 r-8 x-84 r-8 K-2 - S-1 diff --git a/test.it b/test.it index 6a68df3f32726b1b50f1bec087e2acf927d32b0c..cfb80547a1997d299bb13b9c26eb642e38eb1055 100644 GIT binary patch delta 418 zcmeyco$=5P#t8~StPBhcEDX#H27w|>o(yaZ4F(g9`W2b}G5(KcWMEhg#J_>qn~8y8 zHV{7oVqIni2L6e662;s@1Q{hF7#Z9{Zbg@JFaYUKWw#h6TQhnwR!nYVbmnV!02&Sj zGF+3dGdl6PtbqwIFgU49R%ddmPdkVZnqmoI+cPMD8IAllOd2A^AaVYCmo(yaZ4fPX^`lT8F+cGjR^aAl+Al7AKV5kM+ zBS6eE@loRBXhsi4p~*`ao%wDHZ~zT}fNh+UzcM=U-9Zw%qdeJ_$*H~vp@>1x5~A9k zK>^HYw5-A3;8%@|i6eA0m1X>0Z;x}UC5(xk^Rn;KUMqFTd2+xf{1H@x!