1
0
mirror of synced 2024-12-02 18:27:17 +01:00

[malody] beta load support !

This commit is contained in:
Stepland 2021-05-26 22:49:33 +02:00
parent 8ea3c68278
commit d67df121da
13 changed files with 374 additions and 51 deletions

View File

@ -8,8 +8,7 @@ from itertools import count
from typing import Iterator, Union
from jubeatools import song
from .timemap import TimeMap
from jubeatools.formats.timemap import TimeMap
AnyNote = Union[song.TapNote, song.LongNote]

View File

@ -6,9 +6,9 @@ from typing import List
from more_itertools import numeric_range
from jubeatools import song
from jubeatools.formats.timemap import TimeMap
from .commons import AnyNote, Command, Event, bpm_to_value, ticks_at_beat
from .timemap import TimeMap
def make_events_from_chart(notes: List[AnyNote], timing: song.Timing) -> List[Event]:

View File

@ -3,7 +3,7 @@ from hypothesis import strategies as st
from jubeatools import song
from jubeatools.formats.konami.commons import EveLong
from jubeatools.formats.konami.timemap import TimeMap
from jubeatools.formats.timemap import TimeMap
from jubeatools.testutils import strategies as jbst

View File

@ -3,7 +3,7 @@ from fractions import Fraction
from hypothesis import given
from jubeatools import song
from jubeatools.formats.konami.timemap import TimeMap
from jubeatools.formats.timemap import TimeMap
from jubeatools.testutils import strategies as jbst
from jubeatools.utils import group_by

View File

@ -3,6 +3,7 @@ from typing import Iterable, List
from jubeatools import song
from jubeatools.formats.load_tools import round_beats
from jubeatools.formats.timemap import BPMAtSecond, TimeMap
from jubeatools.utils import group_by
from .commons import (
@ -14,7 +15,6 @@ from .commons import (
ticks_to_seconds,
value_to_truncated_bpm,
)
from .timemap import BPMAtSecond, TimeMap
def make_chart_from_events(events: Iterable[Event], beat_snap: int = 240) -> song.Chart:

View File

@ -1,7 +1,7 @@
from decimal import Decimal
from fractions import Fraction
from pathlib import Path
from typing import Dict, Iterable, Protocol, TypeVar, Union
from typing import Dict, Iterable, Optional, Protocol, TypeVar, Union
from jubeatools import song
@ -14,7 +14,7 @@ class FileLoader(Protocol[T_co]):
contents in whatever form suitable for the current format. Returns None in
case of error"""
def __call__(self, path: Path) -> T_co:
def __call__(self, path: Path) -> Optional[T_co]:
...

View File

@ -0,0 +1,6 @@
"""Malody is a multiplatform rhythm game that mainly lives off content created
by its community, as is common in the rhythm game simulator scene. It support
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"""

View File

@ -0,0 +1,120 @@
import json
import warnings
from decimal import Decimal
from fractions import Fraction
from functools import reduce, singledispatch
from pathlib import Path
from typing import Any, List, Optional, Tuple, Union
from jubeatools import song
from jubeatools.formats import timemap
from jubeatools.formats.load_tools import make_folder_loader
from jubeatools.utils import none_or
from . import schema as malody
def load_malody(path: Path, **kwargs: Any) -> song.Song:
files = load_folder(path)
charts = [load_malody_file(d) for d in files.values()]
return reduce(song.Song.merge, charts)
def load_file(path: Path) -> Any:
with path.open() as f:
return json.load(f)
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)
if file.meta.mode != malody.Mode.PAD:
raise ValueError("This file is not a Malody Pad Chart (Malody's jubeat mode)")
bgm = find_bgm(file.note)
metadata = load_metadata(file.meta, bgm)
time_map = load_timing_info(file.time, bgm)
timing = time_map.convert_to_timing_info()
chart = song.Chart(level=Decimal(0), timing=timing, notes=load_notes(file.note))
dif = file.meta.version or song.Difficulty.EXTREME
return song.Song(metadata=metadata, charts={dif: chart})
def find_bgm(events: List[malody.Event]) -> Optional[malody.Sound]:
sounds = [e for e in events if isinstance(e, malody.Sound)]
bgms = [s for s in sounds if s.type == malody.SoundType.BACKGROUND_MUSIC]
if not bgms:
return None
if len(bgms) > 1:
warnings.warn(
"This file defines more than one background music, the first one "
"will be used"
)
return min(bgms, key=lambda b: tuple_to_beats(b.beat))
def load_metadata(meta: malody.Metadata, bgm: Optional[malody.Sound]) -> song.Metadata:
return song.Metadata(
title=meta.song.title,
artist=meta.song.artist,
audio=none_or(lambda b: Path(b.sound), bgm),
cover=none_or(Path, meta.background),
)
def load_timing_info(
bpm_changes: List[malody.BPMEvent], bgm: Optional[malody.Sound]
) -> timemap.TimeMap:
if bgm is None:
offset = timemap.SecondsAtBeat(seconds=Fraction(0), beats=Fraction(0))
else:
offset = timemap.SecondsAtBeat(
seconds=-Fraction(bgm.offset) / 1000, beats=tuple_to_beats(bgm.beat)
)
return timemap.TimeMap.from_beats(
events=[
timemap.BPMAtBeat(beats=tuple_to_beats(b.beat), BPM=Fraction(b.bpm))
for b in bpm_changes
],
offset=offset,
)
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)
return [load_note(n) for n in notes]
@singledispatch
def load_note(
n: Union[malody.TapNote, malody.LongNote]
) -> Union[song.TapNote, song.LongNote]:
raise NotImplementedError(f"Unknown note type : {type(n)}")
@load_note.register
def load_tap_note(n: malody.TapNote) -> song.TapNote:
return song.TapNote(
time=tuple_to_beats(n.beat), position=song.NotePosition.from_index(n.index)
)
@load_note.register
def load_long_note(n: malody.LongNote) -> song.LongNote:
start = tuple_to_beats(n.beat)
end = tuple_to_beats(n.endbeat)
return song.LongNote(
time=start,
position=song.NotePosition.from_index(n.index),
duration=end - start,
tail_tip=song.NotePosition.from_index(n.endindex),
)
def tuple_to_beats(b: Tuple[int, int, int]) -> song.BeatsTime:
return b[0] + song.BeatsTime(b[1], b[2])

View File

@ -0,0 +1,100 @@
from __future__ import annotations
from dataclasses import field
from decimal import Decimal
from enum import Enum
from typing import ClassVar, List, Optional, Tuple, Type, Union
from marshmallow import Schema as ms_Schema
from marshmallow.validate import Range
from marshmallow_dataclass import NewType, dataclass
@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
id: Optional[int]
mode: int
time: int # creation timestamp ?
song: SongInfo
class Mode(int, Enum):
KEY = 0 # Vertical Scrolling Rhythm Game
# 1 : Unused
# 2 : Unused
CATCH = 3 # EZ2CATCH / Catch the Beat
PAD = 4 # Jubeat
TAIKO = 5 # Taiko no Tatsujin
RING = 6 # Reminds me of Beatstream ?
@dataclass
class SongInfo:
title: Optional[str]
artist: Optional[str]
id: Optional[int]
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:
beat: BeatTime
bpm: StrictlyPositiveDecimal
ButtonIndex = NewType("ButtonIndex", int, validate=Range(min=0, max=15))
@dataclass
class TapNote:
beat: BeatTime
index: ButtonIndex
@dataclass
class LongNote:
beat: BeatTime
index: ButtonIndex
endbeat: BeatTime
endindex: ButtonIndex
@dataclass
class Sound:
"""Used both for the background music and keysounds"""
beat: BeatTime
sound: str # audio file path
type: int
offset: int
isBgm: Optional[bool]
vol: Optional[int] # Volume, out of 100
x: Optional[int]
# TODO: find a keysounded chart to discovery the other values
class SoundType(int, Enum):
BACKGROUND_MUSIC = 1
Event = Union[Sound, LongNote, TapNote]

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from fractions import Fraction
from typing import List, Union
@ -18,6 +18,18 @@ class BPMAtSecond:
BPM: Fraction
@dataclass
class BPMAtBeat:
beats: Fraction
BPM: Fraction
@dataclass
class SecondsAtBeat:
seconds: Fraction
beats: Fraction
@dataclass
class BPMChange:
beats: song.BeatsTime
@ -30,48 +42,69 @@ class TimeMap:
"""Wraps a song.Timing to allow converting symbolic time (in beats)
to clock time (in seconds) and back"""
beat_zero_offset: song.SecondsTime
events_by_beats: SortedKeyList[BPMChange, song.BeatsTime]
events_by_seconds: SortedKeyList[BPMChange, Fraction]
@classmethod
def from_timing(cls, beats: song.Timing) -> TimeMap:
def from_timing(cls, timing: song.Timing) -> TimeMap:
"""Create a time map from a song.Timing object"""
if not beats.events:
return cls.from_beats(
events=[
BPMAtBeat(beats=e.time, BPM=Fraction(e.BPM)) for e in timing.events
],
offset=SecondsAtBeat(
seconds=Fraction(timing.beat_zero_offset), beats=Fraction(0)
),
)
@classmethod
def from_beats(cls, events: List[BPMAtBeat], offset: SecondsAtBeat) -> TimeMap:
"""Create a time map from a list of BPM changes with times given in
beats, the offset parameter is more flexible than a "regular" beat zero
offset as it accepts non-zero beats"""
if not events:
raise ValueError("No BPM defined")
grouped_by_time = group_by(beats.events, key=lambda e: e.time)
for time, events in grouped_by_time.items():
if len(events) > 1:
raise ValueError(f"Multiple BPMs defined on beat {time} : {events}")
grouped_by_time = group_by(events, key=lambda e: e.beats)
for time, events_at_time in grouped_by_time.items():
if len(events_at_time) > 1:
raise ValueError(f"Multiple BPMs defined at beat {time} : {events}")
sorted_events = sorted(beats.events, key=lambda e: e.time)
# First compute everything as if the first BPM change happened at
# zero seconds, then shift according to the offset
sorted_events = sorted(events, key=lambda e: e.beats)
first_event = sorted_events[0]
if first_event.time != song.BeatsTime(0):
raise ValueError("First BPM event is not on beat zero")
# set first BPM change then compute from there
current_second = Fraction(beats.beat_zero_offset)
current_second = Fraction(0)
bpm_changes = [
BPMChange(first_event.time, current_second, Fraction(first_event.BPM))
BPMChange(first_event.beats, current_second, Fraction(first_event.BPM))
]
for previous, current in windowed(sorted_events, 2):
if previous is None or current is None:
continue
beats_since_last_event = current.time - previous.time
beats_since_last_event = current.beats - previous.beats
seconds_since_last_event = (60 * beats_since_last_event) / Fraction(
previous.BPM
)
current_second += seconds_since_last_event
bpm_change = BPMChange(current.time, current_second, Fraction(current.BPM))
bpm_change = BPMChange(current.beats, current_second, Fraction(current.BPM))
bpm_changes.append(bpm_change)
return cls(
beat_zero_offset=beats.beat_zero_offset,
not_shifted = cls(
events_by_beats=SortedKeyList(bpm_changes, key=lambda b: b.beats),
events_by_seconds=SortedKeyList(bpm_changes, key=lambda b: b.seconds),
)
unshifted_seconds_at_offset = not_shifted.fractional_seconds_at(offset.beats)
shift = offset.seconds - unshifted_seconds_at_offset
shifted_bpm_changes = [
replace(b, seconds=b.seconds + shift) for b in bpm_changes
]
return cls(
events_by_beats=SortedKeyList(shifted_bpm_changes, key=lambda b: b.beats),
events_by_seconds=SortedKeyList(
shifted_bpm_changes, key=lambda b: b.seconds
),
)
@classmethod
def from_seconds(cls, events: List[BPMAtSecond]) -> TimeMap:
@ -103,7 +136,6 @@ class TimeMap:
bpm_changes.append(bpm_change)
return cls(
beat_zero_offset=fraction_to_decimal(first_event.seconds),
events_by_beats=SortedKeyList(bpm_changes, key=lambda b: b.beats),
events_by_seconds=SortedKeyList(bpm_changes, key=lambda b: b.seconds),
)
@ -113,31 +145,21 @@ class TimeMap:
return fraction_to_decimal(frac_seconds)
def fractional_seconds_at(self, beat: song.BeatsTime) -> Fraction:
if beat < 0:
raise ValueError("Can't compute seconds at negative beat")
# find previous bpm change
index = self.events_by_beats.bisect_key_right(beat) - 1
bpm_change: BPMChange = self.events_by_beats[index]
# compute seconds since last bpm change
"""Before the first bpm change, compute backwards from the first bpm,
after the first bpm change, compute forwards from the previous bpm
change"""
index = self.events_by_beats.bisect_key_right(beat)
first_or_previous_index = max(0, index - 1)
bpm_change: BPMChange = self.events_by_beats[first_or_previous_index]
beats_since_last_event = beat - bpm_change.beats
seconds_since_last_event = (60 * beats_since_last_event) / bpm_change.BPM
return bpm_change.seconds + seconds_since_last_event
def beats_at(self, seconds: Union[song.SecondsTime, Fraction]) -> song.BeatsTime:
if seconds < self.beat_zero_offset:
raise ValueError(
f"Can't compute beat time at {seconds} seconds, since it predates "
f"beat zero, which happens at {self.beat_zero_offset} seconds"
)
# find previous bpm change
frac_seconds = Fraction(seconds)
index = self.events_by_seconds.bisect_key_right(frac_seconds) - 1
bpm_change: BPMChange = self.events_by_seconds[index]
# compute beats since last bpm change
index = self.events_by_seconds.bisect_key_right(frac_seconds)
first_or_previous_index = max(0, index - 1)
bpm_change: BPMChange = self.events_by_seconds[first_or_previous_index]
seconds_since_last_event = frac_seconds - bpm_change.seconds
beats_since_last_event = (bpm_change.BPM * seconds_since_last_event) / Fraction(
60
@ -153,5 +175,5 @@ class TimeMap:
)
for e in self.events_by_beats
],
beat_zero_offset=self.beat_zero_offset,
beat_zero_offset=self.seconds_at(song.BeatsTime(0)),
)

View File

@ -9,6 +9,7 @@ warn_redundant_casts = True
warn_unused_ignores = True
warn_return_any = True
warn_unreachable = True
plugins = marshmallow_dataclass.mypy
[mypy-constraint]
ignore_missing_imports = True

80
poetry.lock generated
View File

@ -189,6 +189,39 @@ docs = ["sphinx (==3.4.3)", "sphinx-issues (==1.2.0)", "alabaster (==0.7.12)", "
lint = ["mypy (==0.812)", "flake8 (==3.9.0)", "flake8-bugbear (==21.3.2)", "pre-commit (>=2.4,<3.0)"]
tests = ["pytest", "pytz", "simplejson"]
[[package]]
name = "marshmallow-dataclass"
version = "8.4.1"
description = "Python library to convert dataclasses into marshmallow schemas."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
marshmallow = ">=3.0.0,<4.0"
marshmallow-enum = {version = "*", optional = true, markers = "extra == \"enum\""}
typeguard = {version = "*", optional = true, markers = "extra == \"union\""}
typing-inspect = "*"
[package.extras]
dev = ["marshmallow-enum", "typeguard", "pre-commit (>=1.18,<2.0)", "sphinx", "pytest (>=5.4)", "pytest-mypy-plugins (>=1.2.0)", "typing-extensions (>=3.7.2,<3.8.0)"]
docs = ["sphinx"]
enum = ["marshmallow-enum"]
lint = ["pre-commit (>=1.18,<2.0)"]
tests = ["pytest (>=5.4)", "pytest-mypy-plugins (>=1.2.0)", "typing-extensions (>=3.7.2,<3.8.0)"]
union = ["typeguard"]
[[package]]
name = "marshmallow-enum"
version = "1.5.1"
description = "Enum field for Marshmallow"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
marshmallow = ">=2.0.0"
[[package]]
name = "mccabe"
version = "0.6.1"
@ -233,7 +266,7 @@ dmypy = ["psutil (>=4.0)"]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
category = "main"
optional = false
python-versions = "*"
@ -430,18 +463,42 @@ category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "typeguard"
version = "2.12.0"
description = "Run-time type checker for Python"
category = "main"
optional = false
python-versions = ">=3.5.3"
[package.extras]
doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
test = ["pytest", "typing-extensions", "mypy"]
[[package]]
name = "typing-extensions"
version = "3.7.4.3"
description = "Backported and Experimental Type Hints for Python 3.5+"
category = "dev"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "typing-inspect"
version = "0.6.0"
description = "Runtime inspection utilities for typing module."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
mypy-extensions = ">=0.3.0"
typing-extensions = ">=3.7.4"
[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "dd2989bf15b7c389b4d6d8f28335f4fea3cb064b41723e5fe500f38dd92c39bb"
content-hash = "0e7f4c7f3e3554861ffd76a0e4143dd5c5218904234ee5641ad106a26f17b2bb"
[metadata.files]
appdirs = [
@ -502,6 +559,14 @@ marshmallow = [
{file = "marshmallow-3.11.1-py2.py3-none-any.whl", hash = "sha256:0dd42891a5ef288217ed6410917f3c6048f585f8692075a0052c24f9bfff9dfd"},
{file = "marshmallow-3.11.1.tar.gz", hash = "sha256:16e99cb7f630c0ef4d7d364ed0109ac194268dde123966076ab3dafb9ae3906b"},
]
marshmallow-dataclass = [
{file = "marshmallow_dataclass-8.4.1-py3-none-any.whl", hash = "sha256:035f4aa9f516ca3c14c9ae3905fe8370b14cea6462ec1a9d4451209a6117976e"},
{file = "marshmallow_dataclass-8.4.1.tar.gz", hash = "sha256:26b6ef76646c6cd71df3163c7106ddeaab27d9fac355cad41046627d5c15cda0"},
]
marshmallow-enum = [
{file = "marshmallow-enum-1.5.1.tar.gz", hash = "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58"},
{file = "marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"},
]
mccabe = [
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
@ -764,8 +829,17 @@ typed-ast = [
{file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
{file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
]
typeguard = [
{file = "typeguard-2.12.0-py3-none-any.whl", hash = "sha256:7d1cf82b35e9ff3cd083133ebda54ad1d7a40296471397e6c6b229cf07fe5307"},
{file = "typeguard-2.12.0.tar.gz", hash = "sha256:fca77fd4ccba63465b421cdbbab5a1a8e3994e6d6f18b45da2bb475c09f147ef"},
]
typing-extensions = [
{file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
{file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
{file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
]
typing-inspect = [
{file = "typing_inspect-0.6.0-py2-none-any.whl", hash = "sha256:de08f50a22955ddec353876df7b2545994d6df08a2f45d54ac8c05e530372ca0"},
{file = "typing_inspect-0.6.0-py3-none-any.whl", hash = "sha256:3b98390df4d999a28cf5b35d8b333425af5da2ece8a4ea9e98f71e7591347b4f"},
{file = "typing_inspect-0.6.0.tar.gz", hash = "sha256:8f1b1dd25908dbfd81d3bebc218011531e7ab614ba6e5bf7826d887c834afab7"},
]

View File

@ -19,6 +19,7 @@ sortedcontainers = "^2.3.0"
python-constraint = "^1.4.0"
construct = "~=2.10"
construct-typing = "^0.4.2"
marshmallow-dataclass = {extras = ["union", "enum"], version = "^8.4.1"}
[tool.poetry.dev-dependencies]
pytest = "^6.2.3"