diff --git a/CHANGELOG.md b/CHANGELOG.md index b32de65..614d157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# v1.2.0 +## Added +- [malody] 🎉 initial malody support ! + # v1.1.3 ## Fixed - [jubeat-analyser] All files are read and written in `surrogateescape` error diff --git a/jubeatools/formats/enum.py b/jubeatools/formats/enum.py index c4d30ae..2681ad1 100644 --- a/jubeatools/formats/enum.py +++ b/jubeatools/formats/enum.py @@ -4,6 +4,7 @@ from enum import Enum class Format(str, Enum): EVE = "eve" JBSQ = "jbsq" + MALODY = "malody" MEMON_LEGACY = "memon:legacy" MEMON_0_1_0 = "memon:v0.1.0" MEMON_0_2_0 = "memon:v0.2.0" diff --git a/jubeatools/formats/formats.py b/jubeatools/formats/formats.py index 970bf25..3efcd53 100644 --- a/jubeatools/formats/formats.py +++ b/jubeatools/formats/formats.py @@ -12,6 +12,7 @@ from .jubeat_analyser import ( load_mono_column, ) from .konami import dump_eve, dump_jbsq, load_eve, load_jbsq +from .malody import dump_malody, load_malody from .memon import ( dump_memon_0_1_0, dump_memon_0_2_0, @@ -25,6 +26,7 @@ from .typing import Dumper, Loader LOADERS: Dict[Format, Loader] = { Format.EVE: load_eve, Format.JBSQ: load_jbsq, + Format.MALODY: load_malody, Format.MEMON_LEGACY: load_memon_legacy, Format.MEMON_0_1_0: load_memon_0_1_0, Format.MEMON_0_2_0: load_memon_0_2_0, @@ -37,6 +39,7 @@ LOADERS: Dict[Format, Loader] = { DUMPERS: Dict[Format, Dumper] = { Format.EVE: dump_eve, Format.JBSQ: dump_jbsq, + Format.MALODY: dump_malody, Format.MEMON_LEGACY: dump_memon_legacy, Format.MEMON_0_1_0: dump_memon_0_1_0, Format.MEMON_0_2_0: dump_memon_0_2_0, diff --git a/jubeatools/formats/guess.py b/jubeatools/formats/guess.py index c4577f7..6b1542a 100644 --- a/jubeatools/formats/guess.py +++ b/jubeatools/formats/guess.py @@ -10,7 +10,7 @@ def guess_format(path: Path) -> Format: raise ValueError("Can't guess chart format for a folder") try: - return recognize_memon_version(path) + return recognize_json_formats(path) except (json.JSONDecodeError, UnicodeDecodeError, ValueError): pass @@ -28,19 +28,26 @@ def guess_format(path: Path) -> Format: raise ValueError("Unrecognized file format") -def recognize_memon_version(path: Path) -> Format: +def recognize_json_formats(path: Path) -> Format: with path.open() as f: obj = json.load(f) + if not isinstance(obj, dict): + raise ValueError("Top level value is not an object") + + if obj.keys() >= {"metadata", "data"}: + return recognize_memon_version(obj) + elif obj.keys() >= {"meta", "time", "note"}: + return Format.MALODY + else: + raise ValueError("Unrecognized file format") + + +def recognize_memon_version(obj: dict) -> Format: try: version = obj["version"] except KeyError: return Format.MEMON_LEGACY - except TypeError: - raise ValueError( - "This JSON file is not a correct memon file : the top-level " - "value is not an object" - ) if version == "0.1.0": return Format.MEMON_0_1_0 diff --git a/jubeatools/formats/konami/jbsq/tests/test_jbsq.py b/jubeatools/formats/konami/jbsq/test_jbsq.py similarity index 94% rename from jubeatools/formats/konami/jbsq/tests/test_jbsq.py rename to jubeatools/formats/konami/jbsq/test_jbsq.py index c44b00e..d8b0749 100644 --- a/jubeatools/formats/konami/jbsq/tests/test_jbsq.py +++ b/jubeatools/formats/konami/jbsq/test_jbsq.py @@ -5,7 +5,7 @@ from jubeatools.formats import Format from jubeatools.formats.konami.testutils import eve_compatible_song, open_temp_dir from jubeatools.testutils.test_patterns import dump_and_load_then_compare -from ..construct import jbsq +from .construct import jbsq @given(eve_compatible_song()) diff --git a/jubeatools/formats/konami/jbsq/tests/__init__.py b/jubeatools/formats/konami/jbsq/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/jubeatools/formats/malody/__init__.py b/jubeatools/formats/malody/__init__.py index b3b1426..d9f4090 100644 --- a/jubeatools/formats/malody/__init__.py +++ b/jubeatools/formats/malody/__init__.py @@ -4,3 +4,6 @@ many different games or "Modes", including jubeat (known as "Pad" Mode) The file format it uses is not that well documented but is simple enough to make sense of without docs. It's a json file with some defined schema""" + +from .dump import dump_malody +from .load import load_malody diff --git a/jubeatools/formats/malody/dump.py b/jubeatools/formats/malody/dump.py index 6e770b4..2d6dac6 100644 --- a/jubeatools/formats/malody/dump.py +++ b/jubeatools/formats/malody/dump.py @@ -1,11 +1,14 @@ -from pathlib import Path -from typing import List -import json import time +from functools import singledispatch +from pathlib import Path +from typing import List, Tuple, Union + +import simplejson as json from jubeatools import song from jubeatools.formats.dump_tools import make_dumper_from_chart_file_dumper from jubeatools.formats.filetypes import ChartFile +from jubeatools.utils import none_or from . import schema as malody @@ -14,8 +17,8 @@ def dump_malody_song(song: song.Song, **kwargs: dict) -> List[ChartFile]: res = [] for dif, chart, timing in song.iter_charts_with_timing(): malody_chart = dump_malody_chart(song.metadata, dif, chart, timing) - json_chart = malody.Chart.Schema().dump(malody_chart) - chart_bytes = json.dumps(json_chart).encode("utf-8") + json_chart = malody.CHART_SCHEMA.dump(malody_chart) + chart_bytes = json.dumps(json_chart, indent=4, use_decimal=True).encode("utf-8") res.append(ChartFile(chart_bytes, song, dif, chart)) return res @@ -26,14 +29,17 @@ dump_malody = make_dumper_from_chart_file_dumper( ) -def dump_malody_chart(metadata: song.Metadata, dif: str, chart: song.Chart, timing: song.Timing) -> malody.Chart: +def dump_malody_chart( + metadata: song.Metadata, dif: str, chart: song.Chart, timing: song.Timing +) -> malody.Chart: meta = dump_metadata(metadata, dif) time = dump_timing(timing) notes = dump_notes(chart.notes) if metadata.audio is not None: - notes += dump_bgm(metadata.audio, timing) + notes += [dump_bgm(metadata.audio, timing)] return malody.Chart(meta=meta, time=time, note=notes) + def dump_metadata(metadata: song.Metadata, dif: str) -> malody.Metadata: return malody.Metadata( cover="", @@ -47,20 +53,23 @@ def dump_metadata(metadata: song.Metadata, dif: str) -> malody.Metadata: title=metadata.title, artist=metadata.artist, id=0, - ) + ), ) + def dump_timing(timing: song.Timing) -> List[malody.BPMEvent]: sorted_events = sorted(timing.events, key=lambda e: e.time) return [dump_bpm_change(e) for e in sorted_events] + def dump_bpm_change(b: song.BPMEvent) -> malody.BPMEvent: return malody.BPMEvent( beat=beats_to_tuple(b.time), bpm=b.BPM, ) -def dump_notes(notes: List[Union[song.TapNote, song.LongNote]]) -> List[Union[malody.TapNote, malody.LongNote]]: + +def dump_notes(notes: List[Union[song.TapNote, song.LongNote]]) -> List[malody.Event]: return [dump_note(n) for n in notes] @@ -78,26 +87,29 @@ def dump_tap_note(n: song.TapNote) -> malody.TapNote: index=n.position.index, ) + @dump_note.register def dump_long_note(n: song.LongNote) -> malody.LongNote: return malody.LongNote( - beat=beats_to_tuple(n.time) + beat=beats_to_tuple(n.time), index=n.position.index, endbeat=beats_to_tuple(n.time + n.duration), endindex=n.tail_tip.index, ) + def dump_bgm(audio: Path, timing: song.Timing) -> malody.Sound: return malody.Sound( beat=beats_to_tuple(song.BeatsTime(0)), sound=str(audio), vol=100, - offset=-int(timing.beat_zero_offset*1000), + offset=-int(timing.beat_zero_offset * 1000), type=malody.SoundType.BACKGROUND_MUSIC, isBgm=None, x=None, ) + def beats_to_tuple(b: song.BeatsTime) -> Tuple[int, int, int]: integer_part = int(b) remainder = b % 1 @@ -105,4 +117,4 @@ def beats_to_tuple(b: song.BeatsTime) -> Tuple[int, int, int]: integer_part, remainder.numerator, remainder.denominator, - ) \ No newline at end of file + ) diff --git a/jubeatools/formats/malody/load.py b/jubeatools/formats/malody/load.py index a244ae6..ca7fe15 100644 --- a/jubeatools/formats/malody/load.py +++ b/jubeatools/formats/malody/load.py @@ -1,4 +1,3 @@ -import json import warnings from decimal import Decimal from fractions import Fraction @@ -6,6 +5,8 @@ from functools import reduce, singledispatch from pathlib import Path from typing import Any, List, Optional, Tuple, Union +import simplejson as json + from jubeatools import song from jubeatools.formats import timemap from jubeatools.formats.load_tools import make_folder_loader @@ -22,14 +23,14 @@ def load_malody(path: Path, **kwargs: Any) -> song.Song: def load_file(path: Path) -> Any: with path.open() as f: - return json.load(f) + return json.load(f, use_decimal=True) load_folder = make_folder_loader("*.mc", load_file) def load_malody_file(raw_dict: dict) -> song.Song: - file: malody.Chart = malody.Chart.Schema().load(raw_dict) + file: malody.Chart = malody.CHART_SCHEMA.load(raw_dict) if file.meta.mode != malody.Mode.PAD: raise ValueError("This file is not a Malody Pad Chart (Malody's jubeat mode)") @@ -86,7 +87,7 @@ def load_timing_info( def load_notes(events: List[malody.Event]) -> List[Union[song.TapNote, song.LongNote]]: # filter out sound events - notes = filter(lambda e: isinstance(e, (malody.TapNote, malody.TapNote)), events) + notes = filter(lambda e: isinstance(e, (malody.TapNote, malody.LongNote)), events) return [load_note(n) for n in notes] diff --git a/jubeatools/formats/malody/schema.py b/jubeatools/formats/malody/schema.py index a48dd1d..8263b6a 100644 --- a/jubeatools/formats/malody/schema.py +++ b/jubeatools/formats/malody/schema.py @@ -1,34 +1,22 @@ -from __future__ import annotations - -from dataclasses import field +from dataclasses import dataclass, field from decimal import Decimal from enum import Enum -from typing import ClassVar, List, Optional, Tuple, Type, Union +from typing import List, Optional, Tuple, Union -from marshmallow import Schema as ms_Schema from marshmallow.validate import Range -from marshmallow_dataclass import NewType, dataclass +from marshmallow_dataclass import NewType, class_schema + + +class Ordered: + class Meta: + ordered = True @dataclass -class Chart: - meta: Metadata - time: List[BPMEvent] = field(default_factory=list) - note: List[Event] = field(default_factory=list) - - Schema: ClassVar[Type[ms_Schema]] = ms_Schema - - -@dataclass -class Metadata: - cover: Optional[str] # path to album art ? - creator: Optional[str] # Chart author - background: Optional[str] # path to background image - version: Optional[str] # freeform difficulty name +class SongInfo(Ordered): + title: Optional[str] + artist: Optional[str] id: Optional[int] - mode: int - time: Optional[int] # creation timestamp ? - song: SongInfo class Mode(int, Enum): @@ -42,22 +30,26 @@ class Mode(int, Enum): @dataclass -class SongInfo: - title: Optional[str] - artist: Optional[str] +class Metadata(Ordered): + cover: Optional[str] # path to album art ? + creator: Optional[str] # Chart author + background: Optional[str] # path to background image + version: Optional[str] # freeform difficulty name id: Optional[int] + mode: int + time: Optional[int] # creation timestamp ? + song: SongInfo PositiveInt = NewType("PositiveInt", int, validate=Range(min=0)) BeatTime = Tuple[PositiveInt, PositiveInt, PositiveInt] - StrictlyPositiveDecimal = NewType( "StrictlyPositiveDecimal", Decimal, validate=Range(min=0, min_inclusive=False) ) @dataclass -class BPMEvent: +class BPMEvent(Ordered): beat: BeatTime bpm: StrictlyPositiveDecimal @@ -66,13 +58,13 @@ ButtonIndex = NewType("ButtonIndex", int, validate=Range(min=0, max=15)) @dataclass -class TapNote: +class TapNote(Ordered): beat: BeatTime index: ButtonIndex @dataclass -class LongNote: +class LongNote(Ordered): beat: BeatTime index: ButtonIndex endbeat: BeatTime @@ -80,7 +72,7 @@ class LongNote: @dataclass -class Sound: +class Sound(Ordered): """Used both for the background music and keysounds""" beat: BeatTime @@ -98,3 +90,13 @@ class SoundType(int, Enum): Event = Union[Sound, LongNote, TapNote] + + +@dataclass +class Chart(Ordered): + meta: Metadata + time: List[BPMEvent] = field(default_factory=list) + note: List[Event] = field(default_factory=list) + + +CHART_SCHEMA = class_schema(Chart)() diff --git a/jubeatools/formats/malody/test_malody.py b/jubeatools/formats/malody/test_malody.py new file mode 100644 index 0000000..b41395d --- /dev/null +++ b/jubeatools/formats/malody/test_malody.py @@ -0,0 +1,32 @@ +from decimal import Decimal + +from hypothesis import given +from hypothesis import strategies as st + +from jubeatools import song +from jubeatools.formats import Format +from jubeatools.formats.konami.testutils import open_temp_dir +from jubeatools.testutils import strategies as jbst +from jubeatools.testutils.test_patterns import dump_and_load_then_compare +from jubeatools.testutils.typing import DrawFunc + + +@st.composite +def malody_compatible_song(draw: DrawFunc) -> song.Song: + """Malody files only hold one chart and have limited metadata""" + diff = draw(st.sampled_from(list(song.Difficulty))).value + chart = draw(jbst.chart(level_strat=st.just(Decimal(0)))) + metadata = draw(jbst.metadata()) + metadata.preview = None + metadata.preview_file = None + return song.Song(metadata=metadata, charts={diff: chart}) + + +@given(malody_compatible_song()) +def test_that_full_chart_roundtrips(song: song.Song) -> None: + dump_and_load_then_compare( + Format.MALODY, + song, + temp_path=open_temp_dir(), + bytes_decoder=lambda b: b.decode("utf-8"), + )