├── __init__.py ├── setup.cfg ├── README.md ├── setup.py ├── test_random_map.py ├── .gitignore ├── slidercalc.py ├── curve.py └── beatmapparser.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-osu-parser 2 | A Python-library to parse .osu-files 3 | 4 | This library is a translation from the Repo [nojhamster/osu-parser](https://github.com/nojhamster/osu-parser) and is supposed to work in the same manner 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup( 6 | name='osuparser', 7 | packages=['osuparser'], 8 | version='0.1', 9 | description='A library to compute .osu-files', 10 | author='Alex Wieser', 11 | author_email='wieseralex580@gmail.com', 12 | url='https://github.com/Awlexus/python-osuparser', 13 | download_url='https://github.com/Awlexus/python-osu-parse/archive/first_release.tar.gz', 14 | keywords=['osu', 'parse', 'beatmap'] 15 | ) 16 | -------------------------------------------------------------------------------- /test_random_map.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | from random import shuffle 4 | 5 | import beatmapparser 6 | 7 | # Created by Awlex 8 | 9 | if __name__ == "__main__": 10 | 11 | # get Songs folder 12 | osu_songs_directory = os.path.join(os.getenv('LOCALAPPDATA'), 'osu!', 'Songs') 13 | 14 | # List Songs and shuffle the list 15 | maps = os.listdir(osu_songs_directory) 16 | shuffle(maps) 17 | 18 | # Pick random map 19 | map_path = os.path.join(osu_songs_directory, maps[0]) 20 | 21 | # Pick first .osu file 22 | file = [x for x in os.listdir(map_path) if x.endswith(".osu")][0] 23 | osu_path = os.path.join(map_path, file) 24 | print(osu_path) 25 | 26 | # init parser 27 | parser = beatmapparser.BeatmapParser() 28 | 29 | # Parse File 30 | time = datetime.datetime.now() 31 | parser.parseFile(osu_path) 32 | print("Parsing done. Time: ", (datetime.datetime.now() - time).microseconds / 1000, 'ms') 33 | 34 | #Build Beatmap 35 | time = datetime.datetime.now() 36 | parser.build_beatmap() 37 | print("Building done. Time: ", (datetime.datetime.now() - time).microseconds / 1000, 'ms') 38 | 39 | quit() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | .idea/* 101 | 102 | # mypy 103 | .mypy_cache/ 104 | MANIFEST 105 | -------------------------------------------------------------------------------- /slidercalc.py: -------------------------------------------------------------------------------- 1 | import math 2 | from curve import Bezier 3 | 4 | 5 | # Translated from JavaScript to Python by Awlex 6 | 7 | def get_end_point(slider_type, slider_length, points): 8 | if not slider_type or not slider_length or not points: 9 | return 10 | 11 | if slider_type == 'linear': 12 | return point_on_line(points[0], points[1], slider_length) 13 | elif slider_type == 'catmull': 14 | # not supported, anyway, it 's only used in old beatmaps 15 | return 'undefined' 16 | elif slider_type == 'bezier': 17 | if not points or len(points) < 2: 18 | return 'undefined' 19 | 20 | if len(points) == 2: 21 | return point_on_line(points[0], points[1], slider_length) 22 | 23 | pts = points[:] 24 | previous = [] 25 | i = 0 26 | l = len(pts) 27 | while i < l: 28 | point = pts[i] 29 | 30 | if not previous: 31 | previous = point 32 | continue 33 | 34 | if point[0] == previous[0] and point[1] == previous[1]: 35 | bezier = Bezier(pts[0:i]) 36 | pts = pts[i:] 37 | slider_length -= bezier.pxlength 38 | i = 0 39 | l = len(pts) 40 | 41 | previous = point 42 | i += 1 43 | 44 | bezier = Bezier(pts) 45 | return bezier.point_at_distance(slider_length) 46 | 47 | elif slider_type == 'pass-through': 48 | if not points or len(points) < 2: 49 | return 'undefined' 50 | 51 | if len(points) == 2: 52 | return point_on_line(points[0], points[1], slider_length) 53 | 54 | if len(points) > 3: 55 | return get_end_point('bezier', slider_length, points) 56 | 57 | p1 = points[0] 58 | p2 = points[1] 59 | p3 = points[2] 60 | 61 | cx, cy, radius = get_circum_circle(p1, p2, p3) 62 | radians = slider_length / radius 63 | if is_left(p1, p2, p3): 64 | radians *= -1 65 | 66 | return rotate(cx, cy, p1[0], p1[1], radians) 67 | 68 | 69 | def point_on_line(p1, p2, length): 70 | full_length = math.sqrt(math.pow(p2[0] - p1[0], 2) + math.pow(p2[1] - p1[1], 2)) 71 | n = full_length - length 72 | 73 | x = (n * p1[0] + length * p2[0]) / full_length 74 | y = (n * p1[1] + length * p2[1]) / full_length 75 | return [x, y] 76 | 77 | 78 | # Get coordinates of a point in a circle, given the center, a startpoint and a distance in radians 79 | # @param {Float} cx center x 80 | # @param {Float} cy center y 81 | # @param {Float} x startpoint x 82 | # @param {Float} y startpoint y 83 | # @param {Float} radians distance from the startpoint 84 | # @return {Object} the new point coordinates after rotation 85 | def rotate(cx, cy, x, y, radians): 86 | cos = math.cos(radians) 87 | sin = math.sin(radians) 88 | 89 | return [ 90 | (cos * (x - cx)) - (sin * (y - cy)) + cx, 91 | (sin * (x - cx)) + (cos * (y - cy)) + cy 92 | ] 93 | 94 | 95 | # Check if C is on left side of [AB] 96 | # @param {Object} a startpoint of the segment 97 | # @param {Object} b endpoint of the segment 98 | # @param {Object} c the point we want to locate 99 | # @return {Boolean} true if on left side 100 | def is_left(a, b, c): 101 | return ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) < 0 102 | 103 | 104 | # Get circum circle of 3 points 105 | # @param {Object} p1 first point 106 | # @param {Object} p2 second point 107 | # @param {Object} p3 third point 108 | # @return {Object} circumCircle 109 | def get_circum_circle(p1, p2, p3): 110 | x1 = p1[0] 111 | y1 = p1[1] 112 | 113 | x2 = p2[0] 114 | y2 = p2[1] 115 | 116 | x3 = p3[0] 117 | y3 = p3[1] 118 | 119 | # center of circle 120 | d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) 121 | 122 | ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / d 123 | uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / d 124 | 125 | px = ux - x1 126 | py = uy - y1 127 | r = math.sqrt(px * px + py * py) 128 | 129 | return ux, uy, r 130 | -------------------------------------------------------------------------------- /curve.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | 4 | # Translated from JavaScript to Python by Awlex 5 | 6 | def is_point_in_circle(point, center, radius): 7 | return distance_points(point, center) <= radius 8 | 9 | 10 | def distance_points(p1, p2): 11 | x = (p1[0] - p2[0]) 12 | y = (p1[1] - p2[1]) 13 | return math.sqrt(x * x + y * y) 14 | 15 | 16 | def distance_from_points(array): 17 | distance = 0 18 | 19 | for i in range(1, len(array)): 20 | distance += distance_points(array[i], array[i - 1]) 21 | 22 | return distance 23 | 24 | 25 | def angle_from_points(p1, p2): 26 | return math.atan2(p2[1] - p1[1], p2[0] - p1[0]) 27 | 28 | 29 | def cart_from_pol(r, teta): 30 | x2 = (r * math.cos(teta)) 31 | y2 = (r * math.sin(teta)) 32 | 33 | return [x2, y2] 34 | 35 | 36 | def point_at_distance(array, distance): 37 | # needs a serious cleanup ! 38 | global new_distance, i 39 | current_distance = 0 40 | 41 | if len(array) < 2: 42 | return [0, 0, 0, 0] 43 | 44 | if distance == 0: 45 | angle = angle_from_points(array[0], array[1]) 46 | return [array[0][0], array[0][1], angle, 0] 47 | 48 | if distance_from_points(array) <= distance: 49 | angle = angle_from_points(array[array.length - 2], array[array.length - 1]) 50 | return [array[array.length - 1][0], 51 | array[array.length - 1][1], 52 | angle, 53 | array.length - 2] 54 | 55 | for i in range(len(array) - 2): 56 | x = (array[i][0] - array[i + 1][0]) 57 | y = (array[i][1] - array[i + 1][1]) 58 | 59 | new_distance = (math.sqrt(x * x + y * y)) 60 | current_distance += new_distance 61 | 62 | if distance <= current_distance: 63 | break 64 | 65 | current_distance -= new_distance 66 | 67 | if distance == current_distance: 68 | coord = [array[i][0], array[i][1]] 69 | angle = angle_from_points(array[i], array[i + 1]) 70 | else: 71 | angle = angle_from_points(array[i], array[i + 1]) 72 | cart = cart_from_pol((distance - current_distance), angle) 73 | 74 | if array[i][0] > array[i + 1][0]: 75 | coord = [(array[i][0] - cart[0]), (array[i][1] - cart[1])] 76 | else: 77 | coord = [(array[i][0] + cart[0]), (array[i][1] + cart[1])] 78 | 79 | return [coord[0], coord[1], angle, i] 80 | 81 | 82 | def cpn(p, n): 83 | if p < 0 or p > n: 84 | return 0 85 | p = min(p, n - p) 86 | out = 1 87 | for i in range(1, p + 1): 88 | out = out * (n - p + i) / i 89 | return out 90 | 91 | 92 | def array_values(array): 93 | out = [] 94 | for i in array: 95 | out.append(array[i]) 96 | return out 97 | 98 | 99 | def array_calc(op, array1, array2): 100 | minimum = min(len(array1), len(array2)) 101 | retour = [] 102 | 103 | for i in range(minimum): 104 | retour.append(array1[i] + op * array2[i]) 105 | 106 | return retour 107 | 108 | 109 | # ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** * 110 | 111 | class Bezier: 112 | def __init__(self, points): 113 | self.points = points 114 | self.order = len(points) 115 | 116 | self.step = (0.0025 / self.order) if self.order > 0 else 1 # // x0.10 117 | self.pos = {} 118 | self.calc_points() 119 | 120 | def at(self, t: int): 121 | 122 | # B(t) = sum_(i=0) ^ n(iparmisn) (1 - t) ^ (n - i) * t ^ i * P_i 123 | if t in self.pos: 124 | return self.pos[t] 125 | 126 | x = 0 127 | y = 0 128 | n = self.order - 1 129 | 130 | for i in range(n + 1): 131 | x += cpn(i, n) * ((1 - t) ** (n - i)) * (t ** i) * self.points[i][0] 132 | y += cpn(i, n) * ((1 - t) ** (n - i)) * (t ** i) * self.points[i][1] 133 | 134 | self.pos[t] = [x, y] 135 | 136 | return [x, y] 137 | 138 | # Changed to approximate length 139 | def calc_points(self): 140 | if len(self.pos): return 141 | 142 | self.pxlength = 0 143 | prev = self.at(0) 144 | i = 0 145 | end = 1 + self.step 146 | 147 | while i < end: 148 | current = self.at(i) 149 | self.pxlength += distance_points(prev, current) 150 | prev = current 151 | i += self.step 152 | # ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** * 153 | 154 | def point_at_distance(self, dist): 155 | return { 156 | 0: False, 157 | 1: self.points[0], 158 | }.get(self.order, self.rec(dist)) 159 | 160 | def rec(self, dist): 161 | self.calc_points() 162 | return point_at_distance(array_values(self.pos), dist)[:2] 163 | 164 | 165 | # ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** * # 166 | 167 | class Catmull: 168 | def __init__(self, points): 169 | self.points = points 170 | self.order = len(points) 171 | 172 | self.step = 0.025 173 | self.pos = [] 174 | self.calc_points() 175 | 176 | def at(self, x, t): 177 | v1 = self.points[x - 1] if x >= 1 else self.points[x] 178 | v2 = self.points[x] 179 | v3 = self.points[x + 1] if x + 1 < self.order else array_calc('1', v2, array_calc('-1', v2, v1)) 180 | v4 = self.points[x + 2] if x + 2 < self.order else array_calc('1', v3, array_calc('-1', v3, v2)) 181 | 182 | retour = [] 183 | for i in range(2): 184 | retour[i] = 0.5 * ( 185 | (-v1[i] + 3 * v2[i] - 3 * v3[i] + v4[i]) * t * t * t + ( 186 | 2 * v1[i] - 5 * v2[i] + 4 * v3[i] - v4[i]) * t * t + ( 187 | -v1[i] + v3[i]) * t + 2 * v2[i]) 188 | 189 | return retour 190 | 191 | def calc_points(self): 192 | if len(self.pos): 193 | return 194 | for i in range(self.order - 1): 195 | for t in range(start=0, stop=1 + self.step, step=self.step): 196 | self.pos.append(self.at(i, t)) 197 | 198 | def point_at_distance(self, dist): 199 | return { 200 | 0: False, 201 | 1: self.points[0], 202 | }.get(self.order, self.rec(dist)) 203 | 204 | def rec(self, dist): 205 | self.calc_points() 206 | return point_at_distance(array_values(self.pos), dist)[:2] 207 | -------------------------------------------------------------------------------- /beatmapparser.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import math 3 | import os 4 | import re 5 | 6 | import slidercalc 7 | 8 | 9 | # Translated from JavaScript to Python by Awlex 10 | 11 | class BeatmapParser(): 12 | def __init__(self): 13 | 14 | self.osu_section = None 15 | self.beatmap = { 16 | "nbCircles": 0, 17 | "nbSliders": 0, 18 | "nbSpinners": 0, 19 | "timingPoints": [], 20 | "breakTimes": [], 21 | "hitObjects": [] 22 | } 23 | 24 | self.timing_lines = [] 25 | self.object_lines = [] 26 | self.events_lines = [] 27 | self.section_reg = re.compile('^\[([a-zA-Z0-9]+)\]$') 28 | self.key_val_reg = re.compile('^([a-zA-Z0-9]+)[ ]*:[ ]*(.+)$') 29 | self.curve_types = { 30 | "C": "catmull", 31 | "B": "bezier", 32 | "L": "linear", 33 | "P": "pass-through" 34 | } 35 | 36 | # Get the timing point affecting a specific offset 37 | # @param {Integer} offset 38 | # @return {Object} timingPoint 39 | def get_timing_point(self, offset): 40 | for i in reversed(range(len(self.beatmap["timingPoints"]))): 41 | if self.beatmap["timingPoints"][i]["offset"] <= offset: 42 | return self.beatmap["timingPoints"][i] 43 | return self.beatmap["timingPoints"][0] 44 | 45 | # Parse additions member 46 | # @param {String} str additions member (sample:add:customSampleIndex:Volume:hitsound) 47 | # @return {Object} additions a list of additions 48 | def parse_additions(self, line): 49 | if not line: 50 | return {} 51 | 52 | additions = {} 53 | adds = line.split(':') 54 | 55 | if adds[0] and adds[0] != '0': 56 | additions["sample"] = { 57 | '1': 'normal', 58 | '2': 'soft', 59 | '3': 'drum' 60 | }[adds[0]] 61 | 62 | if adds[1] and adds[1] != '0': 63 | additions["additionalSample"] = { 64 | '1': 'normal', 65 | '2': 'soft', 66 | '3': 'drum' 67 | }[adds[1]] 68 | 69 | if len(adds) > 2 and adds[2] and adds[2] != '0': 70 | additions["customSampleIndex"] = int(adds[2]) 71 | if len(adds) > 3 and adds[3] and adds[3] != '0': 72 | additions["hitsoundVolume"] = int(adds[3]) 73 | if len(adds) > 4 and adds[4]: 74 | additions["hitsound"] = adds[4] 75 | return additions 76 | 77 | # Parse a timing line 78 | # @param {String} line 79 | def parse_timing_point(self, line): 80 | members = line.split(',') 81 | 82 | timing_point = { 83 | "offset": int(float(members[0])), 84 | "beatLength": float(members[1]), 85 | "velocity": 1, 86 | "timingSignature": int(members[2]), 87 | "sampleSetId": int(members[3]), 88 | "customSampleIndex": int(members[4]), 89 | "sampleVolume": int(members[5]), 90 | "timingChange": (members[6] == 1), 91 | "kiaiTimeActive": (members[7] == 1) 92 | } 93 | 94 | if not math.isnan(timing_point["beatLength"]) and timing_point["beatLength"] != 0: 95 | if timing_point["beatLength"] > 0: 96 | # If positive, beatLength is the length of a beat in milliseconds 97 | bpm = round(60000 / timing_point["beatLength"]) 98 | self.beatmap["bpmMin"] = min(self.beatmap["bpmMin"], bpm) if "bpmMin" in self.beatmap else bpm 99 | self.beatmap["bpmMax"] = max(self.beatmap["bpmMax"], bpm) if "bpmMax" in self.beatmap else bpm 100 | timing_point["bpm"] = bpm 101 | else: 102 | # If negative, beatLength is a velocity factor 103 | timing_point["velocity"] = abs(100 / timing_point["beatLength"]) 104 | 105 | self.beatmap["timingPoints"].append(timing_point) 106 | 107 | # Parse an object line 108 | # @param {String} line 109 | def parse_hit_object(self, line): 110 | members = line.split(',') 111 | 112 | sound_type = int(members[4]) 113 | object_type = int(members[3]) 114 | 115 | hit_object = { 116 | "startTime": int(members[2]), 117 | "newCombo": object_type & 4, 118 | "soundTypes": [], 119 | "position": [ 120 | int(members[0]), 121 | int(members[1]) 122 | ] 123 | } 124 | 125 | # sound type is a bitwise flag enum 126 | # 0 : normal 127 | # 2 : whistle 128 | # 4 : finish 129 | # 8 : clap 130 | if sound_type & 2: 131 | hit_object["soundTypes"].append('whistle') 132 | if sound_type & 4: 133 | hit_object["soundTypes"].append('finish') 134 | 135 | if sound_type & 8: 136 | hit_object["soundTypes"].append('clap') 137 | 138 | if not len(hit_object["soundTypes"]): 139 | hit_object["soundTypes"].append('normal') 140 | 141 | # object type is a bitwise flag enum 142 | # 1: circle 143 | # 2: slider 144 | # 8: spinner 145 | if object_type & 1: 146 | # Circle 147 | self.beatmap["nbCircles"] += 1 148 | hit_object["object_name"] = 'circle' 149 | if len(members) > 6: 150 | hit_object["additions"] = self.parse_additions(members[5]) 151 | elif object_type & 8: 152 | # Spinner 153 | self.beatmap["nbSpinners"] += 1 154 | hit_object["object_name"] = 'spinner' 155 | hit_object["end_time"] = int(members[5]) 156 | if len(members) > 7: 157 | hit_object["additions"] = self.parse_additions(members[6]) 158 | elif object_type & 2: 159 | # Slider 160 | try: 161 | self.beatmap["nbSliders"] += 1 162 | hit_object["object_name"] = 'slider' 163 | hit_object["repeatCount"] = int(members[6]) 164 | hit_object["pixelLength"] = int(round(float(members[7]))) 165 | if len(members) > 10: 166 | hit_object["additions"] = self.parse_additions(members[10]) 167 | hit_object["edges"] = [] 168 | hit_object["points"] = [ 169 | [hit_object["position"][0], hit_object["position"][1]] 170 | ] 171 | except Exception as e: 172 | raise e 173 | 174 | # Calculate slider duration 175 | timing = self.get_timing_point(hit_object["startTime"]) 176 | 177 | if timing: 178 | px_per_beat = float(self.beatmap["SliderMultiplier"]) * 100 * float(timing["velocity"]) 179 | beats_number = (hit_object["pixelLength"] * int(hit_object["repeatCount"])) / px_per_beat 180 | hit_object["duration"] = math.ceil(beats_number * timing["beatLength"]) 181 | hit_object["end_time"] = hit_object["startTime"] + hit_object["duration"] 182 | 183 | # Parse slider points 184 | points = (members[5] or '').split('|') 185 | if len(points): 186 | hit_object["curveType"] = self.curve_types[points[0]] or 'unknown' 187 | 188 | for i in range(1, len(points)): 189 | coordinates = points[i].split(':') 190 | hit_object["points"].append([ 191 | int(coordinates[0]), 192 | int(coordinates[1]) 193 | ]) 194 | 195 | if len(members) > 9: 196 | edge_sounds = [] 197 | edge_additions = [] 198 | if members[8]: 199 | edge_sounds = members[8].split('|') 200 | 201 | if members[9]: 202 | edge_additions = members[9].split('|') 203 | 204 | # Get soundTypes and additions for each slider edge 205 | for j in range(hit_object["repeatCount"] + 1): 206 | edge = { 207 | "soundTypes": [], 208 | "additions": self.parse_additions(edge_additions[j]) 209 | } 210 | 211 | if edge_sounds[j]: 212 | sound = int(edge_sounds[j]) 213 | if sound & 2: 214 | edge["soundTypes"].append('whistle') 215 | 216 | if sound & 4: 217 | edge["soundTypes"].append('finish') 218 | 219 | if sound & 8: 220 | edge["soundTypes"].append('clap') 221 | 222 | if not len(edge["soundTypes"]): 223 | edge["soundTypes"].append('normal') 224 | 225 | else: 226 | edge["soundTypes"].append('normal') 227 | 228 | hit_object["edges"].append(edge) 229 | 230 | # get coordinates of the slider endpoint 231 | end_point = slidercalc.get_end_point(hit_object["curveType"], hit_object["pixelLength"], 232 | hit_object["points"]) 233 | if end_point and end_point[0] and end_point[1]: 234 | hit_object["end_position"] = [ 235 | round(end_point[0]), 236 | round(end_point[1]) 237 | ] 238 | else: 239 | # If endPosition could not be calculated, approximate it by setting it to the last point 240 | hit_object["end_position"] = hit_object["points"][len(hit_object["points"]) - 1] 241 | 242 | else: 243 | # Unknown 244 | hit_object["object_name"] = 'unknown' 245 | 246 | self.beatmap["hitObjects"].append(hit_object) 247 | 248 | # Parse an event line 249 | # @param {String} line 250 | def parse_event(self, line): 251 | # Background line : 0,0,"bg.jpg" 252 | # TODO: confirm that the second member is always zero 253 | # 254 | # Breaktimes lines : 2,1000,2000 255 | # second integer is start offset 256 | # third integer is end offset 257 | members = line.split(',') 258 | 259 | if members[0] == '0' and members[1] == '0' and members[2]: 260 | bg_name = members[2].trim() 261 | 262 | if bg_name[0] == '"' and bg_name[len(bg_name) - 1] == '"': 263 | self.beatmap["bg_filename"] = bg_name.substring(1, bg_name.length - 1) 264 | else: 265 | self.beatmap["bg_filename"] = bg_name 266 | elif members[0] == '2' and re.search('/^[0-9]+$/', members[1]) and re.search('/^[0-9]+$/', members[2]): 267 | self.beatmap["breakTimes"].append({ 268 | "startTime": int(members[1]), 269 | "endTime": int(members[2]) 270 | }) 271 | 272 | # Compute the total time and the draining time of the beatmap 273 | def compute_duration(self): 274 | if not len(self.beatmap["hitObjects"]): 275 | return 276 | first_object = self.beatmap["hitObjects"][0] 277 | last_object = self.beatmap["hitObjects"][len(self.beatmap["hitObjects"]) - 1] 278 | 279 | total_break_time = 0 280 | 281 | for break_time in self.beatmap["breakTimes"]: 282 | total_break_time += (break_time.endTime - break_time.startTime) 283 | 284 | if first_object and last_object: 285 | self.beatmap["total_time"] = math.floor(last_object["startTime"] / 1000) 286 | self.beatmap["draining_time"] = math.floor( 287 | (last_object["startTime"] - first_object["startTime"] - total_break_time) / 1000) 288 | else: 289 | self.beatmap["total_time"] = 0 290 | self.beatmap["draining_time"] = 0 291 | 292 | # Browse objects and compute max combo 293 | def compute_max_combo(self): 294 | if not len(self.beatmap["timingPoints"]): 295 | return 296 | 297 | max_combo = 0 298 | slider_multiplier = float(self.beatmap["SliderMultiplier"]) 299 | slider_tick_rate = float(self.beatmap["SliderTickRate"]) 300 | 301 | timing_points = self.beatmap["timingPoints"] 302 | current_timing = timing_points[0] 303 | next_offset = timing_points[1]["offset"] if len(timing_points) > 1 else math.inf 304 | i = 1 305 | 306 | for hit_object in self.beatmap["hitObjects"]: 307 | if hit_object["startTime"] >= next_offset: 308 | current_timing = timing_points[i] 309 | i += 1 310 | next_offset = timing_points[i]["offsxet"] if i in timing_points else math.inf 311 | 312 | osupx_per_beat = slider_multiplier * 100 * current_timing["velocity"] 313 | tick_length = osupx_per_beat / slider_tick_rate 314 | 315 | if hit_object["object_name"] == 'spinner' or hit_object["object_name"]== 'circle': 316 | max_combo += 1 317 | elif hit_object["object_name"]== 'slider': 318 | tick_per_side = math.ceil((math.floor(hit_object["pixelLength"] / tick_length * 100) / 100) - 1) 319 | max_combo += (len(hit_object["edges"]) - 1) * ( 320 | tick_per_side + 1) + 1 # 1 combo for each tick and endpoint 321 | 322 | self.beatmap["maxCombo"] = max_combo 323 | 324 | # Read a single line, parse when key/value, store when further parsing needed 325 | # @param {String|Buffer} line 326 | def read_line(self, line: str): 327 | line = line.strip() 328 | if not line: 329 | return 330 | 331 | match = self.section_reg.match(line) 332 | if match: 333 | self.osu_section = match.group(1).lower() 334 | return 335 | 336 | if self.osu_section == 'timingpoints': 337 | self.timing_lines.append(line) 338 | elif self.osu_section == 'hitobjects': 339 | self.object_lines.append(line) 340 | self.events_lines.append(line) 341 | else: 342 | match = re.match('^osu file format (v[0-9]+)$', line) 343 | if match: 344 | self.beatmap["fileFormat"] = match.group(1) 345 | 346 | # Apart from events, timingpoints and hitobjects sections, lines are "key: value" 347 | match = self.key_val_reg.match(line) 348 | if match: 349 | self.beatmap[match.group(1)] = match.group(2) 350 | 351 | # Compute everything that require the file to be completely parsed and return the beatmap 352 | # @return {Object} beatmap 353 | def build_beatmap(self): 354 | if "Tags" in self.beatmap: 355 | self.beatmap["Tags"] = str(self.beatmap["Tags"]).split(" ") 356 | 357 | for event_line in self.events_lines: 358 | self.parse_event(event_line) 359 | self.beatmap["breakTimes"].sort(key=lambda a, b: 1 if a.startTime > b.startTime else -1) 360 | 361 | for timing_line in self.timing_lines: 362 | self.parse_timing_point(timing_line) 363 | self.beatmap["timingPoints"].sort(key=lambda a: a['offset']) 364 | timing_points = self.beatmap["timingPoints"] 365 | 366 | for i in range(1, len(timing_points)): 367 | if not "bpm" in timing_points[i]: 368 | timing_points[i]["beatLength"] = timing_points[i - 1]["beatLength"] 369 | timing_points[i]["bpm"] = timing_points[i - 1]["bpm"] 370 | 371 | for object_line in self.object_lines: 372 | self.parse_hit_object(object_line) 373 | self.beatmap["hitObjects"].sort(key=lambda a: a["startTime"]) 374 | self.compute_max_combo() 375 | self.compute_duration() 376 | return self.beatmap 377 | 378 | # return { 379 | # "readLine": readLine, 380 | # "buildBeatmap": buildBeatmap 381 | # } 382 | 383 | # Parse a .osu file 384 | # @param {String} file path to the file 385 | 386 | def parseFile(self, file): 387 | if os.path.isfile(file): 388 | 389 | with codecs.open(file, 'r', encoding="utf-8") as file: 390 | line = file.readline() 391 | while line: 392 | self.read_line(line) 393 | line = file.readline() --------------------------------------------------------------------------------