1
0
mirror of synced 2024-12-13 15:31:06 +01:00

[malody] test pass !

This commit is contained in:
Stepland 2021-05-28 01:11:27 +02:00
parent 7b110e2462
commit b613a2d960
11 changed files with 120 additions and 55 deletions

View File

@ -1,3 +1,7 @@
# v1.2.0
## Added
- [malody] 🎉 initial malody support !
# v1.1.3 # v1.1.3
## Fixed ## Fixed
- [jubeat-analyser] All files are read and written in `surrogateescape` error - [jubeat-analyser] All files are read and written in `surrogateescape` error

View File

@ -4,6 +4,7 @@ from enum import Enum
class Format(str, Enum): class Format(str, Enum):
EVE = "eve" EVE = "eve"
JBSQ = "jbsq" JBSQ = "jbsq"
MALODY = "malody"
MEMON_LEGACY = "memon:legacy" MEMON_LEGACY = "memon:legacy"
MEMON_0_1_0 = "memon:v0.1.0" MEMON_0_1_0 = "memon:v0.1.0"
MEMON_0_2_0 = "memon:v0.2.0" MEMON_0_2_0 = "memon:v0.2.0"

View File

@ -12,6 +12,7 @@ from .jubeat_analyser import (
load_mono_column, load_mono_column,
) )
from .konami import dump_eve, dump_jbsq, load_eve, load_jbsq from .konami import dump_eve, dump_jbsq, load_eve, load_jbsq
from .malody import dump_malody, load_malody
from .memon import ( from .memon import (
dump_memon_0_1_0, dump_memon_0_1_0,
dump_memon_0_2_0, dump_memon_0_2_0,
@ -25,6 +26,7 @@ from .typing import Dumper, Loader
LOADERS: Dict[Format, Loader] = { LOADERS: Dict[Format, Loader] = {
Format.EVE: load_eve, Format.EVE: load_eve,
Format.JBSQ: load_jbsq, Format.JBSQ: load_jbsq,
Format.MALODY: load_malody,
Format.MEMON_LEGACY: load_memon_legacy, Format.MEMON_LEGACY: load_memon_legacy,
Format.MEMON_0_1_0: load_memon_0_1_0, Format.MEMON_0_1_0: load_memon_0_1_0,
Format.MEMON_0_2_0: load_memon_0_2_0, Format.MEMON_0_2_0: load_memon_0_2_0,
@ -37,6 +39,7 @@ LOADERS: Dict[Format, Loader] = {
DUMPERS: Dict[Format, Dumper] = { DUMPERS: Dict[Format, Dumper] = {
Format.EVE: dump_eve, Format.EVE: dump_eve,
Format.JBSQ: dump_jbsq, Format.JBSQ: dump_jbsq,
Format.MALODY: dump_malody,
Format.MEMON_LEGACY: dump_memon_legacy, Format.MEMON_LEGACY: dump_memon_legacy,
Format.MEMON_0_1_0: dump_memon_0_1_0, Format.MEMON_0_1_0: dump_memon_0_1_0,
Format.MEMON_0_2_0: dump_memon_0_2_0, Format.MEMON_0_2_0: dump_memon_0_2_0,

View File

@ -10,7 +10,7 @@ def guess_format(path: Path) -> Format:
raise ValueError("Can't guess chart format for a folder") raise ValueError("Can't guess chart format for a folder")
try: try:
return recognize_memon_version(path) return recognize_json_formats(path)
except (json.JSONDecodeError, UnicodeDecodeError, ValueError): except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
pass pass
@ -28,19 +28,26 @@ def guess_format(path: Path) -> Format:
raise ValueError("Unrecognized file 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: with path.open() as f:
obj = json.load(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: try:
version = obj["version"] version = obj["version"]
except KeyError: except KeyError:
return Format.MEMON_LEGACY 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": if version == "0.1.0":
return Format.MEMON_0_1_0 return Format.MEMON_0_1_0

View File

@ -5,7 +5,7 @@ from jubeatools.formats import Format
from jubeatools.formats.konami.testutils import eve_compatible_song, open_temp_dir from jubeatools.formats.konami.testutils import eve_compatible_song, open_temp_dir
from jubeatools.testutils.test_patterns import dump_and_load_then_compare from jubeatools.testutils.test_patterns import dump_and_load_then_compare
from ..construct import jbsq from .construct import jbsq
@given(eve_compatible_song()) @given(eve_compatible_song())

View File

@ -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 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""" make sense of without docs. It's a json file with some defined schema"""
from .dump import dump_malody
from .load import load_malody

View File

@ -1,11 +1,14 @@
from pathlib import Path
from typing import List
import json
import time 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 import song
from jubeatools.formats.dump_tools import make_dumper_from_chart_file_dumper from jubeatools.formats.dump_tools import make_dumper_from_chart_file_dumper
from jubeatools.formats.filetypes import ChartFile from jubeatools.formats.filetypes import ChartFile
from jubeatools.utils import none_or
from . import schema as malody from . import schema as malody
@ -14,8 +17,8 @@ def dump_malody_song(song: song.Song, **kwargs: dict) -> List[ChartFile]:
res = [] res = []
for dif, chart, timing in song.iter_charts_with_timing(): for dif, chart, timing in song.iter_charts_with_timing():
malody_chart = dump_malody_chart(song.metadata, dif, chart, timing) malody_chart = dump_malody_chart(song.metadata, dif, chart, timing)
json_chart = malody.Chart.Schema().dump(malody_chart) json_chart = malody.CHART_SCHEMA.dump(malody_chart)
chart_bytes = json.dumps(json_chart).encode("utf-8") chart_bytes = json.dumps(json_chart, indent=4, use_decimal=True).encode("utf-8")
res.append(ChartFile(chart_bytes, song, dif, chart)) res.append(ChartFile(chart_bytes, song, dif, chart))
return res 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) meta = dump_metadata(metadata, dif)
time = dump_timing(timing) time = dump_timing(timing)
notes = dump_notes(chart.notes) notes = dump_notes(chart.notes)
if metadata.audio is not None: 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) return malody.Chart(meta=meta, time=time, note=notes)
def dump_metadata(metadata: song.Metadata, dif: str) -> malody.Metadata: def dump_metadata(metadata: song.Metadata, dif: str) -> malody.Metadata:
return malody.Metadata( return malody.Metadata(
cover="", cover="",
@ -47,20 +53,23 @@ def dump_metadata(metadata: song.Metadata, dif: str) -> malody.Metadata:
title=metadata.title, title=metadata.title,
artist=metadata.artist, artist=metadata.artist,
id=0, id=0,
) ),
) )
def dump_timing(timing: song.Timing) -> List[malody.BPMEvent]: def dump_timing(timing: song.Timing) -> List[malody.BPMEvent]:
sorted_events = sorted(timing.events, key=lambda e: e.time) sorted_events = sorted(timing.events, key=lambda e: e.time)
return [dump_bpm_change(e) for e in sorted_events] return [dump_bpm_change(e) for e in sorted_events]
def dump_bpm_change(b: song.BPMEvent) -> malody.BPMEvent: def dump_bpm_change(b: song.BPMEvent) -> malody.BPMEvent:
return malody.BPMEvent( return malody.BPMEvent(
beat=beats_to_tuple(b.time), beat=beats_to_tuple(b.time),
bpm=b.BPM, 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] 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, index=n.position.index,
) )
@dump_note.register @dump_note.register
def dump_long_note(n: song.LongNote) -> malody.LongNote: def dump_long_note(n: song.LongNote) -> malody.LongNote:
return malody.LongNote( return malody.LongNote(
beat=beats_to_tuple(n.time) beat=beats_to_tuple(n.time),
index=n.position.index, index=n.position.index,
endbeat=beats_to_tuple(n.time + n.duration), endbeat=beats_to_tuple(n.time + n.duration),
endindex=n.tail_tip.index, endindex=n.tail_tip.index,
) )
def dump_bgm(audio: Path, timing: song.Timing) -> malody.Sound: def dump_bgm(audio: Path, timing: song.Timing) -> malody.Sound:
return malody.Sound( return malody.Sound(
beat=beats_to_tuple(song.BeatsTime(0)), beat=beats_to_tuple(song.BeatsTime(0)),
sound=str(audio), sound=str(audio),
vol=100, vol=100,
offset=-int(timing.beat_zero_offset*1000), offset=-int(timing.beat_zero_offset * 1000),
type=malody.SoundType.BACKGROUND_MUSIC, type=malody.SoundType.BACKGROUND_MUSIC,
isBgm=None, isBgm=None,
x=None, x=None,
) )
def beats_to_tuple(b: song.BeatsTime) -> Tuple[int, int, int]: def beats_to_tuple(b: song.BeatsTime) -> Tuple[int, int, int]:
integer_part = int(b) integer_part = int(b)
remainder = b % 1 remainder = b % 1

View File

@ -1,4 +1,3 @@
import json
import warnings import warnings
from decimal import Decimal from decimal import Decimal
from fractions import Fraction from fractions import Fraction
@ -6,6 +5,8 @@ from functools import reduce, singledispatch
from pathlib import Path from pathlib import Path
from typing import Any, List, Optional, Tuple, Union from typing import Any, List, Optional, Tuple, Union
import simplejson as json
from jubeatools import song from jubeatools import song
from jubeatools.formats import timemap from jubeatools.formats import timemap
from jubeatools.formats.load_tools import make_folder_loader 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: def load_file(path: Path) -> Any:
with path.open() as f: with path.open() as f:
return json.load(f) return json.load(f, use_decimal=True)
load_folder = make_folder_loader("*.mc", load_file) load_folder = make_folder_loader("*.mc", load_file)
def load_malody_file(raw_dict: dict) -> song.Song: 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: if file.meta.mode != malody.Mode.PAD:
raise ValueError("This file is not a Malody Pad Chart (Malody's jubeat mode)") 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]]: def load_notes(events: List[malody.Event]) -> List[Union[song.TapNote, song.LongNote]]:
# filter out sound events # 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] return [load_note(n) for n in notes]

View File

@ -1,34 +1,22 @@
from __future__ import annotations from dataclasses import dataclass, field
from dataclasses import field
from decimal import Decimal from decimal import Decimal
from enum import Enum 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.validate import Range
from marshmallow_dataclass import NewType, dataclass from marshmallow_dataclass import NewType, class_schema
class Ordered:
class Meta:
ordered = True
@dataclass @dataclass
class Chart: class SongInfo(Ordered):
meta: Metadata title: Optional[str]
time: List[BPMEvent] = field(default_factory=list) artist: Optional[str]
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
id: Optional[int] id: Optional[int]
mode: int
time: Optional[int] # creation timestamp ?
song: SongInfo
class Mode(int, Enum): class Mode(int, Enum):
@ -42,22 +30,26 @@ class Mode(int, Enum):
@dataclass @dataclass
class SongInfo: class Metadata(Ordered):
title: Optional[str] cover: Optional[str] # path to album art ?
artist: Optional[str] creator: Optional[str] # Chart author
background: Optional[str] # path to background image
version: Optional[str] # freeform difficulty name
id: Optional[int] id: Optional[int]
mode: int
time: Optional[int] # creation timestamp ?
song: SongInfo
PositiveInt = NewType("PositiveInt", int, validate=Range(min=0)) PositiveInt = NewType("PositiveInt", int, validate=Range(min=0))
BeatTime = Tuple[PositiveInt, PositiveInt, PositiveInt] BeatTime = Tuple[PositiveInt, PositiveInt, PositiveInt]
StrictlyPositiveDecimal = NewType( StrictlyPositiveDecimal = NewType(
"StrictlyPositiveDecimal", Decimal, validate=Range(min=0, min_inclusive=False) "StrictlyPositiveDecimal", Decimal, validate=Range(min=0, min_inclusive=False)
) )
@dataclass @dataclass
class BPMEvent: class BPMEvent(Ordered):
beat: BeatTime beat: BeatTime
bpm: StrictlyPositiveDecimal bpm: StrictlyPositiveDecimal
@ -66,13 +58,13 @@ ButtonIndex = NewType("ButtonIndex", int, validate=Range(min=0, max=15))
@dataclass @dataclass
class TapNote: class TapNote(Ordered):
beat: BeatTime beat: BeatTime
index: ButtonIndex index: ButtonIndex
@dataclass @dataclass
class LongNote: class LongNote(Ordered):
beat: BeatTime beat: BeatTime
index: ButtonIndex index: ButtonIndex
endbeat: BeatTime endbeat: BeatTime
@ -80,7 +72,7 @@ class LongNote:
@dataclass @dataclass
class Sound: class Sound(Ordered):
"""Used both for the background music and keysounds""" """Used both for the background music and keysounds"""
beat: BeatTime beat: BeatTime
@ -98,3 +90,13 @@ class SoundType(int, Enum):
Event = Union[Sound, LongNote, TapNote] 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)()

View File

@ -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"),
)