diff --git a/jubeatools/formats/memon.py b/jubeatools/formats/memon.py index 7c4e72e..0e5338a 100644 --- a/jubeatools/formats/memon.py +++ b/jubeatools/formats/memon.py @@ -8,13 +8,13 @@ parse than existing "memo-like" formats (memo, youbeat, etc ...). https://github.com/Stepland/memon """ -import warnings -from path import Path -from typing import Mapping, IO, Iterable, Tuple, Any, Dict, Union, List +from typing import IO, Iterable, Tuple, Any, Dict, Union, List from io import BytesIO from itertools import chain +from path import Path import simplejson as json +from marshmallow import Schema, fields, RAISE, validate, validates_schema, ValidationError from jubeatools.song import Song, BPMChange, TapNote, LongNote from jubeatools.utils import lcm @@ -29,7 +29,7 @@ from jubeatools.utils import lcm # 6 # 10 -LONG_NOTE_VALUE_V0 = { +X_Y_OFFSET_TO_P_VALUE = { (0, -1): 0, (0, -2): 4, (0, -3): 8, @@ -44,9 +44,101 @@ LONG_NOTE_VALUE_V0 = { (-3, 0): 11 } +P_VALUE_TO_X_Y_OFFSET = { v: k for k, v in X_Y_OFFSET_TO_P_VALUE.items() } + + +class StrictSchema(Schema): + class Meta: + unknown = RAISE + + +class MemonNote(StrictSchema): + n = fields.Integer(required=True, validate=validate.Range(min=0, max=15)) + t = fields.Integer(required=True, validate=validate.Range(min=0)) + l = fields.Integer(required=True, validate=validate.Range(min=0)) + p = fields.Integer(required=True, validate=validate.Range(min=0, max=11)) + + @validates_schema + def validate_tail_tip_position(self, data, **kwargs): + if data["l"] > 0: + x = data["n"] % 4 + y = data["n"] // 4 + dx, dy = P_VALUE_TO_X_Y_OFFSET[data["p"]] + if (not (0 <= x + dx < 4 and 0 <= y + dy < 4)): + raise ValidationError("Invalid tail position : {data}") + + +class MemonChart_0_1_0(StrictSchema): + level = fields.Integer(required=True) + resolution = fields.Integer(required=True, validate=validate.Range(min=1)) + notes = fields.Nested(MemonNote, many=True) + + +class MemonChart_legacy(MemonChart_0_1_0): + dif_name = fields.String(required=True) + + +class MemonMetadata_legacy(StrictSchema): + title = fields.String(required=True, data_key="song title") + artist = fields.String(required=True) + audio = fields.String(required=True, data_key="music path") + cover = fields.String(required=True, data_key="jacket path") + BPM = fields.Decimal(required=True, validate=validate.Range(min=0, min_inclusive=False)) + offset = fields.Decimal(required=True) + + +class MemonMetadata_0_1_0(MemonMetadata_legacy): + cover = fields.String(required=True, data_key="album cover path") + + +class MemonPreview(StrictSchema): + position = fields.Decimal(required=True, validate=validate.Range(min=0)) + length = fields.Decimal(required=True, validate=validate.Range(min=0, min_inclusive=False)) + + +class MemonMetadata_0_2_0(MemonMetadata_0_1_0): + preview = fields.Nested(MemonPreview) + + +class Memon_legacy(StrictSchema): + metadata = fields.Nested(MemonMetadata_legacy, required=True) + data = fields.Nested(MemonChart_legacy, required=True, many=True) + + +class Memon_0_1_0(StrictSchema): + version = fields.String(required=True, validate=validate.OneOf(["0.1.0"])) + metadata = fields.Nested(MemonMetadata_0_1_0, required=True) + data = fields.Dict(keys=fields.String(), values=MemonChart_0_1_0(), required=True) + + +class Memon_0_2_0(StrictSchema): + version = fields.String(required=True, validate=validate.OneOf(["0.2.0"])) + metadata = fields.Nested(MemonMetadata_0_2_0, required=True) + data = fields.Dict(keys=fields.String(), values=MemonChart_0_1_0(), required=True) + + +def _search_and_load(file_or_folder: Path) -> Any: + + """If given a folder, search for a single .memon file then json.load it + If given a file, just json.load it""" + + if file_or_folder.isdir(): + memon_files = file_or_folder.files("*.memon") + if len(memon_files) > 1: + raise ValueError(f"Multiple memon files found in {file_or_folder}") + elif len(memon_files) == 0: + raise ValueError(f"No memon file found in {file_or_folder}") + file_path = memon_files[0] + else: + file_path = file_or_folder + + return json.load(open(file_path), use_decimal=True) + def load_memon_legacy(file_or_folder: Path) -> Song: - ... + memon = _search_and_load(file_or_folder) + + def load_memon_0_1_0(file_or_folder: Path) -> Song: @@ -61,10 +153,11 @@ def _long_note_tail_value_v0(note: LongNote) -> int: dx = note.tail_tip.x - note.position.x dy = note.tail_tip.y - note.position.y try: - return LONG_NOTE_VALUE_V0[dx, dy] + return X_Y_OFFSET_TO_P_VALUE[dx, dy] except KeyError: raise ValueError(f"memon cannot represent a long note with its tail starting ({dx}, {dy}) away from the note") from None + def check_representable_in_v0(song: Song, version: str) -> None: """Raises an exception if the Song object is ill-formed or contains information @@ -114,6 +207,7 @@ def _compute_resolution(notes: List[Union[TapNote, LongNote]]) -> int: def _iter_dump_notes_v0(resolution: int, notes: List[Union[TapNote, LongNote]]) -> Iterable[Dict[str, int]]: + """Iterable that converts notes into the {n, t, l, p} form""" for note in sorted(set(notes), key=lambda n: (n.time, n.position)): memon_note = { "n": note.index, @@ -132,7 +226,6 @@ def dump_memon_legacy(song: Song) -> Iterable[Tuple[Any, IO]]: check_representable_in_v0(song, "legacy") - # JSON object preparation memon = { "metadata": { "song title": song.metadata.title, @@ -160,7 +253,6 @@ def dump_memon_0_1_0(song: Song, folder: Path) -> None: check_representable_in_v0(song, "legacy") - # JSON object preparation memon = { "version": "0.1.0", "metadata": { @@ -188,7 +280,6 @@ def dump_memon_0_2_0(song: Song, folder: Path) -> None: check_representable_in_v0(song, "legacy") - # JSON object preparation memon = { "version": "0.2.0", "metadata": { @@ -198,13 +289,16 @@ def dump_memon_0_2_0(song: Song, folder: Path) -> None: "album cover path": str(song.metadata.cover), "BPM": song.global_timing.events[0].BPM, "offset": song.global_timing.beat_zero_offset, - "preview" : { - "position": song.metadata.preview_start, - "length": song.metadata.preview_length, - } }, "data": {} } + + if song.metadata.preview_length != 0: + memon["metadata"]["preview"] = { + "position": song.metadata.preview_start, + "length": song.metadata.preview_length, + } + for difficulty, chart in song.charts.items(): resolution = _compute_resolution(chart.notes) memon["data"][difficulty] = { diff --git a/poetry.lock b/poetry.lock index 3422bb8..477c2c0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -66,6 +66,20 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "0.4.3" +[[package]] +category = "main" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +name = "marshmallow" +optional = false +python-versions = ">=3.5" +version = "3.6.0" + +[package.extras] +dev = ["pytest", "pytz", "simplejson", "mypy (0.770)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)", "tox"] +docs = ["sphinx (3.0.3)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)"] +lint = ["mypy (0.770)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)"] +tests = ["pytest", "pytz", "simplejson"] + [[package]] category = "dev" description = "More routines for operating on iterables, beyond itertools" @@ -223,7 +237,7 @@ python-versions = "*" version = "0.1.9" [metadata] -content-hash = "4c6754363fc490de2f3c7af8747cd102854de6437c41de5612bc8c1f14cb7cd9" +content-hash = "b55c6b2244d11c0356dc49e32615244f2b964ac0b903d8613a5e0e453557643f" python-versions = "^3.8" [metadata.files] @@ -251,6 +265,10 @@ colorama = [ {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, ] +marshmallow = [ + {file = "marshmallow-3.6.0-py2.py3-none-any.whl", hash = "sha256:f88fe96434b1f0f476d54224d59333eba8ca1a203a2695683c1855675c4049a7"}, + {file = "marshmallow-3.6.0.tar.gz", hash = "sha256:c2673233aa21dde264b84349dc2fd1dce5f30ed724a0a00e75426734de5b84ab"}, +] more-itertools = [ {file = "more-itertools-8.3.0.tar.gz", hash = "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be"}, {file = "more_itertools-8.3.0-py3-none-any.whl", hash = "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982"}, diff --git a/pyproject.toml b/pyproject.toml index 01d738f..760d0ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ multidict = "^4.7.6" click = "^7.1.2" path = "^14.0.1" simplejson = "^3.17.0" +marshmallow = "^3.6.0" [tool.poetry.dev-dependencies] pytest = "^5.2"