[malody] beta load support !
This commit is contained in:
parent
8ea3c68278
commit
d67df121da
@ -8,8 +8,7 @@ from itertools import count
|
|||||||
from typing import Iterator, Union
|
from typing import Iterator, Union
|
||||||
|
|
||||||
from jubeatools import song
|
from jubeatools import song
|
||||||
|
from jubeatools.formats.timemap import TimeMap
|
||||||
from .timemap import TimeMap
|
|
||||||
|
|
||||||
AnyNote = Union[song.TapNote, song.LongNote]
|
AnyNote = Union[song.TapNote, song.LongNote]
|
||||||
|
|
||||||
|
@ -6,9 +6,9 @@ from typing import List
|
|||||||
from more_itertools import numeric_range
|
from more_itertools import numeric_range
|
||||||
|
|
||||||
from jubeatools import song
|
from jubeatools import song
|
||||||
|
from jubeatools.formats.timemap import TimeMap
|
||||||
|
|
||||||
from .commons import AnyNote, Command, Event, bpm_to_value, ticks_at_beat
|
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]:
|
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 import song
|
||||||
from jubeatools.formats.konami.commons import EveLong
|
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
|
from jubeatools.testutils import strategies as jbst
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ from fractions import Fraction
|
|||||||
from hypothesis import given
|
from hypothesis import given
|
||||||
|
|
||||||
from jubeatools import song
|
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.testutils import strategies as jbst
|
||||||
from jubeatools.utils import group_by
|
from jubeatools.utils import group_by
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ from typing import Iterable, List
|
|||||||
|
|
||||||
from jubeatools import song
|
from jubeatools import song
|
||||||
from jubeatools.formats.load_tools import round_beats
|
from jubeatools.formats.load_tools import round_beats
|
||||||
|
from jubeatools.formats.timemap import BPMAtSecond, TimeMap
|
||||||
from jubeatools.utils import group_by
|
from jubeatools.utils import group_by
|
||||||
|
|
||||||
from .commons import (
|
from .commons import (
|
||||||
@ -14,7 +15,6 @@ from .commons import (
|
|||||||
ticks_to_seconds,
|
ticks_to_seconds,
|
||||||
value_to_truncated_bpm,
|
value_to_truncated_bpm,
|
||||||
)
|
)
|
||||||
from .timemap import BPMAtSecond, TimeMap
|
|
||||||
|
|
||||||
|
|
||||||
def make_chart_from_events(events: Iterable[Event], beat_snap: int = 240) -> song.Chart:
|
def make_chart_from_events(events: Iterable[Event], beat_snap: int = 240) -> song.Chart:
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from pathlib import Path
|
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
|
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
|
contents in whatever form suitable for the current format. Returns None in
|
||||||
case of error"""
|
case of error"""
|
||||||
|
|
||||||
def __call__(self, path: Path) -> T_co:
|
def __call__(self, path: Path) -> Optional[T_co]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
6
jubeatools/formats/malody/__init__.py
Normal file
6
jubeatools/formats/malody/__init__.py
Normal 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"""
|
120
jubeatools/formats/malody/load.py
Normal file
120
jubeatools/formats/malody/load.py
Normal 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])
|
100
jubeatools/formats/malody/schema.py
Normal file
100
jubeatools/formats/malody/schema.py
Normal 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]
|
@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
|
||||||
@ -18,6 +18,18 @@ class BPMAtSecond:
|
|||||||
BPM: Fraction
|
BPM: Fraction
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BPMAtBeat:
|
||||||
|
beats: Fraction
|
||||||
|
BPM: Fraction
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SecondsAtBeat:
|
||||||
|
seconds: Fraction
|
||||||
|
beats: Fraction
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BPMChange:
|
class BPMChange:
|
||||||
beats: song.BeatsTime
|
beats: song.BeatsTime
|
||||||
@ -30,48 +42,69 @@ class TimeMap:
|
|||||||
"""Wraps a song.Timing to allow converting symbolic time (in beats)
|
"""Wraps a song.Timing to allow converting symbolic time (in beats)
|
||||||
to clock time (in seconds) and back"""
|
to clock time (in seconds) and back"""
|
||||||
|
|
||||||
beat_zero_offset: song.SecondsTime
|
|
||||||
events_by_beats: SortedKeyList[BPMChange, song.BeatsTime]
|
events_by_beats: SortedKeyList[BPMChange, song.BeatsTime]
|
||||||
events_by_seconds: SortedKeyList[BPMChange, Fraction]
|
events_by_seconds: SortedKeyList[BPMChange, Fraction]
|
||||||
|
|
||||||
@classmethod
|
@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"""
|
"""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")
|
raise ValueError("No BPM defined")
|
||||||
|
|
||||||
grouped_by_time = group_by(beats.events, key=lambda e: e.time)
|
grouped_by_time = group_by(events, key=lambda e: e.beats)
|
||||||
for time, events in grouped_by_time.items():
|
for time, events_at_time in grouped_by_time.items():
|
||||||
if len(events) > 1:
|
if len(events_at_time) > 1:
|
||||||
raise ValueError(f"Multiple BPMs defined on beat {time} : {events}")
|
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]
|
first_event = sorted_events[0]
|
||||||
if first_event.time != song.BeatsTime(0):
|
current_second = Fraction(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)
|
|
||||||
bpm_changes = [
|
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):
|
for previous, current in windowed(sorted_events, 2):
|
||||||
if previous is None or current is None:
|
if previous is None or current is None:
|
||||||
continue
|
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(
|
seconds_since_last_event = (60 * beats_since_last_event) / Fraction(
|
||||||
previous.BPM
|
previous.BPM
|
||||||
)
|
)
|
||||||
current_second += seconds_since_last_event
|
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)
|
bpm_changes.append(bpm_change)
|
||||||
|
|
||||||
return cls(
|
not_shifted = cls(
|
||||||
beat_zero_offset=beats.beat_zero_offset,
|
|
||||||
events_by_beats=SortedKeyList(bpm_changes, key=lambda b: b.beats),
|
events_by_beats=SortedKeyList(bpm_changes, key=lambda b: b.beats),
|
||||||
events_by_seconds=SortedKeyList(bpm_changes, key=lambda b: b.seconds),
|
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
|
@classmethod
|
||||||
def from_seconds(cls, events: List[BPMAtSecond]) -> TimeMap:
|
def from_seconds(cls, events: List[BPMAtSecond]) -> TimeMap:
|
||||||
@ -103,7 +136,6 @@ class TimeMap:
|
|||||||
bpm_changes.append(bpm_change)
|
bpm_changes.append(bpm_change)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
beat_zero_offset=fraction_to_decimal(first_event.seconds),
|
|
||||||
events_by_beats=SortedKeyList(bpm_changes, key=lambda b: b.beats),
|
events_by_beats=SortedKeyList(bpm_changes, key=lambda b: b.beats),
|
||||||
events_by_seconds=SortedKeyList(bpm_changes, key=lambda b: b.seconds),
|
events_by_seconds=SortedKeyList(bpm_changes, key=lambda b: b.seconds),
|
||||||
)
|
)
|
||||||
@ -113,31 +145,21 @@ class TimeMap:
|
|||||||
return fraction_to_decimal(frac_seconds)
|
return fraction_to_decimal(frac_seconds)
|
||||||
|
|
||||||
def fractional_seconds_at(self, beat: song.BeatsTime) -> Fraction:
|
def fractional_seconds_at(self, beat: song.BeatsTime) -> Fraction:
|
||||||
if beat < 0:
|
"""Before the first bpm change, compute backwards from the first bpm,
|
||||||
raise ValueError("Can't compute seconds at negative beat")
|
after the first bpm change, compute forwards from the previous bpm
|
||||||
|
change"""
|
||||||
# find previous bpm change
|
index = self.events_by_beats.bisect_key_right(beat)
|
||||||
index = self.events_by_beats.bisect_key_right(beat) - 1
|
first_or_previous_index = max(0, index - 1)
|
||||||
bpm_change: BPMChange = self.events_by_beats[index]
|
bpm_change: BPMChange = self.events_by_beats[first_or_previous_index]
|
||||||
|
|
||||||
# compute seconds since last bpm change
|
|
||||||
beats_since_last_event = beat - bpm_change.beats
|
beats_since_last_event = beat - bpm_change.beats
|
||||||
seconds_since_last_event = (60 * beats_since_last_event) / bpm_change.BPM
|
seconds_since_last_event = (60 * beats_since_last_event) / bpm_change.BPM
|
||||||
return bpm_change.seconds + seconds_since_last_event
|
return bpm_change.seconds + seconds_since_last_event
|
||||||
|
|
||||||
def beats_at(self, seconds: Union[song.SecondsTime, Fraction]) -> song.BeatsTime:
|
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)
|
frac_seconds = Fraction(seconds)
|
||||||
index = self.events_by_seconds.bisect_key_right(frac_seconds) - 1
|
index = self.events_by_seconds.bisect_key_right(frac_seconds)
|
||||||
bpm_change: BPMChange = self.events_by_seconds[index]
|
first_or_previous_index = max(0, index - 1)
|
||||||
|
bpm_change: BPMChange = self.events_by_seconds[first_or_previous_index]
|
||||||
# compute beats since last bpm change
|
|
||||||
seconds_since_last_event = frac_seconds - bpm_change.seconds
|
seconds_since_last_event = frac_seconds - bpm_change.seconds
|
||||||
beats_since_last_event = (bpm_change.BPM * seconds_since_last_event) / Fraction(
|
beats_since_last_event = (bpm_change.BPM * seconds_since_last_event) / Fraction(
|
||||||
60
|
60
|
||||||
@ -153,5 +175,5 @@ class TimeMap:
|
|||||||
)
|
)
|
||||||
for e in self.events_by_beats
|
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_unused_ignores = True
|
||||||
warn_return_any = True
|
warn_return_any = True
|
||||||
warn_unreachable = True
|
warn_unreachable = True
|
||||||
|
plugins = marshmallow_dataclass.mypy
|
||||||
|
|
||||||
[mypy-constraint]
|
[mypy-constraint]
|
||||||
ignore_missing_imports = True
|
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)"]
|
lint = ["mypy (==0.812)", "flake8 (==3.9.0)", "flake8-bugbear (==21.3.2)", "pre-commit (>=2.4,<3.0)"]
|
||||||
tests = ["pytest", "pytz", "simplejson"]
|
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]]
|
[[package]]
|
||||||
name = "mccabe"
|
name = "mccabe"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@ -233,7 +266,7 @@ dmypy = ["psutil (>=4.0)"]
|
|||||||
name = "mypy-extensions"
|
name = "mypy-extensions"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
@ -430,18 +463,42 @@ category = "dev"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "3.7.4.3"
|
version = "3.7.4.3"
|
||||||
description = "Backported and Experimental Type Hints for Python 3.5+"
|
description = "Backported and Experimental Type Hints for Python 3.5+"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.8"
|
python-versions = "^3.8"
|
||||||
content-hash = "dd2989bf15b7c389b4d6d8f28335f4fea3cb064b41723e5fe500f38dd92c39bb"
|
content-hash = "0e7f4c7f3e3554861ffd76a0e4143dd5c5218904234ee5641ad106a26f17b2bb"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
appdirs = [
|
appdirs = [
|
||||||
@ -502,6 +559,14 @@ marshmallow = [
|
|||||||
{file = "marshmallow-3.11.1-py2.py3-none-any.whl", hash = "sha256:0dd42891a5ef288217ed6410917f3c6048f585f8692075a0052c24f9bfff9dfd"},
|
{file = "marshmallow-3.11.1-py2.py3-none-any.whl", hash = "sha256:0dd42891a5ef288217ed6410917f3c6048f585f8692075a0052c24f9bfff9dfd"},
|
||||||
{file = "marshmallow-3.11.1.tar.gz", hash = "sha256:16e99cb7f630c0ef4d7d364ed0109ac194268dde123966076ab3dafb9ae3906b"},
|
{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 = [
|
mccabe = [
|
||||||
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
|
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
|
||||||
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
|
{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-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
|
||||||
{file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
|
{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 = [
|
typing-extensions = [
|
||||||
{file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
|
{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-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
|
||||||
{file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
|
{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"
|
python-constraint = "^1.4.0"
|
||||||
construct = "~=2.10"
|
construct = "~=2.10"
|
||||||
construct-typing = "^0.4.2"
|
construct-typing = "^0.4.2"
|
||||||
|
marshmallow-dataclass = {extras = ["union", "enum"], version = "^8.4.1"}
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^6.2.3"
|
pytest = "^6.2.3"
|
||||||
|
Loading…
Reference in New Issue
Block a user