[malody] test pass !
This commit is contained in:
parent
7b110e2462
commit
b613a2d960
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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())
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
@ -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]
|
||||
|
||||
|
||||
|
@ -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)()
|
||||
|
32
jubeatools/formats/malody/test_malody.py
Normal file
32
jubeatools/formats/malody/test_malody.py
Normal 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"),
|
||||
)
|
Loading…
Reference in New Issue
Block a user