commit
28208d74b7
@ -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
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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]:
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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())
|
@ -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:
|
||||
|
@ -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]:
|
||||
...
|
||||
|
||||
|
||||
|
9
jubeatools/formats/malody/__init__.py
Normal file
9
jubeatools/formats/malody/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""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"""
|
||||
|
||||
from .dump import dump_malody
|
||||
from .load import load_malody
|
120
jubeatools/formats/malody/dump.py
Normal file
120
jubeatools/formats/malody/dump.py
Normal file
@ -0,0 +1,120 @@
|
||||
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
|
||||
|
||||
|
||||
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, indent=4, use_decimal=True).encode("utf-8")
|
||||
res.append(ChartFile(chart_bytes, song, dif, chart))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
dump_malody = make_dumper_from_chart_file_dumper(
|
||||
internal_dumper=dump_malody_song, file_name_template=Path("{difficulty:l}.mc")
|
||||
)
|
||||
|
||||
|
||||
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)]
|
||||
return malody.Chart(meta=meta, time=time, note=notes)
|
||||
|
||||
|
||||
def dump_metadata(metadata: song.Metadata, dif: str) -> malody.Metadata:
|
||||
return malody.Metadata(
|
||||
cover="",
|
||||
creator="",
|
||||
background=none_or(str, metadata.cover),
|
||||
version=dif,
|
||||
id=0,
|
||||
mode=malody.Mode.PAD,
|
||||
time=int(time.time()),
|
||||
song=malody.SongInfo(
|
||||
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[malody.Event]:
|
||||
return [dump_note(n) for n in notes]
|
||||
|
||||
|
||||
@singledispatch
|
||||
def dump_note(
|
||||
n: Union[song.TapNote, song.LongNote]
|
||||
) -> Union[malody.TapNote, malody.LongNote]:
|
||||
raise NotImplementedError(f"Unknown note type : {type(n)}")
|
||||
|
||||
|
||||
@dump_note.register
|
||||
def dump_tap_note(n: song.TapNote) -> malody.TapNote:
|
||||
return malody.TapNote(
|
||||
beat=beats_to_tuple(n.time),
|
||||
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),
|
||||
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),
|
||||
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
|
||||
return (
|
||||
integer_part,
|
||||
remainder.numerator,
|
||||
remainder.denominator,
|
||||
)
|
121
jubeatools/formats/malody/load.py
Normal file
121
jubeatools/formats/malody/load.py
Normal file
@ -0,0 +1,121 @@
|
||||
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
|
||||
|
||||
import simplejson as json
|
||||
|
||||
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, 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)
|
||||
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.LongNote)), 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])
|
102
jubeatools/formats/malody/schema.py
Normal file
102
jubeatools/formats/malody/schema.py
Normal file
@ -0,0 +1,102 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
from marshmallow.validate import Range
|
||||
from marshmallow_dataclass import NewType, class_schema
|
||||
|
||||
|
||||
class Ordered:
|
||||
class Meta:
|
||||
ordered = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongInfo(Ordered):
|
||||
title: Optional[str]
|
||||
artist: Optional[str]
|
||||
id: Optional[int]
|
||||
|
||||
|
||||
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 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(Ordered):
|
||||
beat: BeatTime
|
||||
bpm: StrictlyPositiveDecimal
|
||||
|
||||
|
||||
ButtonIndex = NewType("ButtonIndex", int, validate=Range(min=0, max=15))
|
||||
|
||||
|
||||
@dataclass
|
||||
class TapNote(Ordered):
|
||||
beat: BeatTime
|
||||
index: ButtonIndex
|
||||
|
||||
|
||||
@dataclass
|
||||
class LongNote(Ordered):
|
||||
beat: BeatTime
|
||||
index: ButtonIndex
|
||||
endbeat: BeatTime
|
||||
endindex: ButtonIndex
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sound(Ordered):
|
||||
"""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]
|
||||
|
||||
|
||||
@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"),
|
||||
)
|
@ -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)),
|
||||
)
|
1
mypy.ini
1
mypy.ini
@ -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
80
poetry.lock
generated
@ -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"},
|
||||
]
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user