[memon] fix several bugs in metadata handling
This commit is contained in:
parent
857dd899a7
commit
e564229f4f
@ -11,6 +11,9 @@
|
||||
- [#memo2]
|
||||
- Fix parsing of BPM changes
|
||||
- Fix dumping of BPM changes
|
||||
- [memon]
|
||||
- Fix handling of paths-type values in metadata
|
||||
- Fix handling of charts with decimal level value
|
||||
|
||||
# v0.1.3
|
||||
## Changed
|
||||
|
@ -1,12 +1,17 @@
|
||||
"""
|
||||
This module contains code for the different formats read by jubeat analyser
|
||||
This module contains code for the different formats read by "jubeat analyser".
|
||||
|
||||
"memo" is a vague term that refers to several legacy formats.
|
||||
They were originally derived from the (somewhat) human-readable format choosen
|
||||
by websites gathering official jubeat charts in text form as a memory aid.
|
||||
"jubeat analyser" is a Windows program that can play back chart files and
|
||||
export them to video files, it can also be used as a jubeat simulator.
|
||||
|
||||
The machine-readable variants are partially documented (in japanese)
|
||||
on these pages :
|
||||
"memo" is a vague term that refers to several slightly different formats.
|
||||
My understanding is that they were all originally derived from the (somewhat)
|
||||
human-readable format choosen by websites like jubeat memo or cosmos memo.
|
||||
These websites would provide text transcripts of official jubeat charts as
|
||||
training material for hardcore players.
|
||||
|
||||
The machine-readable variants or these text formats are partially documented
|
||||
(in japanese) on these pages :
|
||||
- http://yosh52.web.fc2.com/jubeat/fumenformat.html
|
||||
- http://yosh52.web.fc2.com/jubeat/holdmarker.html
|
||||
"""
|
||||
|
@ -8,456 +8,11 @@ parse than existing "memo-like" formats (memo, youbeat, etc ...).
|
||||
https://github.com/Stepland/memon
|
||||
"""
|
||||
|
||||
from io import StringIO
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import IO, Any, Dict, Iterable, List, Mapping, Tuple, Union
|
||||
|
||||
import simplejson as json
|
||||
from marshmallow import (
|
||||
RAISE,
|
||||
Schema,
|
||||
ValidationError,
|
||||
fields,
|
||||
post_load,
|
||||
validate,
|
||||
validates_schema,
|
||||
from .memon import (
|
||||
dump_memon_0_1_0,
|
||||
dump_memon_0_2_0,
|
||||
dump_memon_legacy,
|
||||
load_memon_0_1_0,
|
||||
load_memon_0_2_0,
|
||||
load_memon_legacy,
|
||||
)
|
||||
|
||||
from jubeatools.song import *
|
||||
from jubeatools.utils import lcm
|
||||
|
||||
# v0.x.x long note value :
|
||||
#
|
||||
# 8
|
||||
# 4
|
||||
# 0
|
||||
# 11 7 3 . 1 5 9
|
||||
# 2
|
||||
# 6
|
||||
# 10
|
||||
|
||||
X_Y_OFFSET_TO_P_VALUE = {
|
||||
(0, -1): 0,
|
||||
(0, -2): 4,
|
||||
(0, -3): 8,
|
||||
(0, 1): 2,
|
||||
(0, 2): 6,
|
||||
(0, 3): 10,
|
||||
(1, 0): 1,
|
||||
(2, 0): 5,
|
||||
(3, 0): 9,
|
||||
(-1, 0): 3,
|
||||
(-2, 0): 7,
|
||||
(-3, 0): 11,
|
||||
}
|
||||
|
||||
P_VALUE_TO_X_Y_OFFSET = {v: k for k, v in X_Y_OFFSET_TO_P_VALUE.items()}
|
||||
|
||||
|
||||
class StrictSchema(Schema):
|
||||
class Meta:
|
||||
unknown = RAISE
|
||||
|
||||
|
||||
class MemonNote(StrictSchema):
|
||||
n = fields.Integer(required=True, validate=validate.Range(min=0, max=15))
|
||||
t = fields.Integer(required=True, validate=validate.Range(min=0))
|
||||
l = fields.Integer(required=True, validate=validate.Range(min=0))
|
||||
p = fields.Integer(required=True, validate=validate.Range(min=0, max=11))
|
||||
|
||||
@validates_schema
|
||||
def validate_tail_tip_position(self, data: Dict[str, int], **kwargs: Any) -> None:
|
||||
if data["l"] > 0:
|
||||
x = data["n"] % 4
|
||||
y = data["n"] // 4
|
||||
dx, dy = P_VALUE_TO_X_Y_OFFSET[data["p"]]
|
||||
if not (0 <= x + dx < 4 and 0 <= y + dy < 4):
|
||||
raise ValidationError("Invalid tail position : {data}")
|
||||
|
||||
|
||||
class MemonChart_0_1_0(StrictSchema):
|
||||
level = fields.Integer(required=True)
|
||||
resolution = fields.Integer(required=True, validate=validate.Range(min=1))
|
||||
notes = fields.Nested(MemonNote, many=True)
|
||||
|
||||
|
||||
class MemonChart_legacy(MemonChart_0_1_0):
|
||||
dif_name = fields.String(required=True)
|
||||
|
||||
|
||||
class MemonMetadata_legacy(StrictSchema):
|
||||
title = fields.String(required=True, data_key="song title")
|
||||
artist = fields.String(required=True)
|
||||
audio = fields.String(required=True, data_key="music path")
|
||||
cover = fields.String(required=True, data_key="jacket path")
|
||||
BPM = fields.Decimal(
|
||||
required=True, validate=validate.Range(min=0, min_inclusive=False)
|
||||
)
|
||||
offset = fields.Decimal(required=True)
|
||||
|
||||
|
||||
class MemonMetadata_0_1_0(MemonMetadata_legacy):
|
||||
cover = fields.String(required=True, data_key="album cover path")
|
||||
|
||||
|
||||
class MemonPreview(StrictSchema):
|
||||
position = fields.Decimal(required=True, validate=validate.Range(min=0))
|
||||
length = fields.Decimal(
|
||||
required=True, validate=validate.Range(min=0, min_inclusive=False)
|
||||
)
|
||||
|
||||
|
||||
class MemonMetadata_0_2_0(MemonMetadata_0_1_0):
|
||||
preview = fields.Nested(MemonPreview)
|
||||
|
||||
|
||||
class Memon_legacy(StrictSchema):
|
||||
metadata = fields.Nested(MemonMetadata_legacy, required=True)
|
||||
data = fields.Nested(MemonChart_legacy, required=True, many=True)
|
||||
|
||||
|
||||
class Memon_0_1_0(StrictSchema):
|
||||
version = fields.String(required=True, validate=validate.OneOf(["0.1.0"]))
|
||||
metadata = fields.Nested(MemonMetadata_0_1_0, required=True)
|
||||
data = fields.Dict(
|
||||
keys=fields.String(), values=fields.Nested(MemonChart_0_1_0), required=True
|
||||
)
|
||||
|
||||
|
||||
class Memon_0_2_0(StrictSchema):
|
||||
version = fields.String(required=True, validate=validate.OneOf(["0.2.0"]))
|
||||
metadata = fields.Nested(MemonMetadata_0_2_0, required=True)
|
||||
data = fields.Dict(
|
||||
keys=fields.String(), values=fields.Nested(MemonChart_0_1_0), required=True
|
||||
)
|
||||
|
||||
|
||||
def _load_raw_memon(file: Path) -> Dict[str, Any]:
|
||||
with open(file) as f:
|
||||
res = json.load(f, use_decimal=True)
|
||||
if not isinstance(res, dict):
|
||||
raise ValueError(
|
||||
"JSON file does not represent a valid memon file : "
|
||||
"The top level of a memon file should be a JSON Object"
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
def _load_memon_note_v0(note: dict, resolution: int) -> Union[TapNote, LongNote]:
|
||||
position = NotePosition.from_index(note["n"])
|
||||
time = beats_time_from_ticks(ticks=note["t"], resolution=resolution)
|
||||
if note["l"] > 0:
|
||||
duration = beats_time_from_ticks(ticks=note["l"], resolution=resolution)
|
||||
tail_tip = position + NotePosition(*P_VALUE_TO_X_Y_OFFSET[note["p"]])
|
||||
return LongNote(time, position, duration, tail_tip)
|
||||
else:
|
||||
return TapNote(time, position)
|
||||
|
||||
|
||||
def load_memon_legacy(file: Path) -> Song:
|
||||
raw_memon = _load_raw_memon(file)
|
||||
schema = Memon_legacy()
|
||||
memon = schema.load(raw_memon)
|
||||
metadata = Metadata(
|
||||
**{key: memon["metadata"][key] for key in ["title", "artist", "audio", "cover"]}
|
||||
)
|
||||
global_timing = Timing(
|
||||
events=[BPMEvent(time=BeatsTime(0), BPM=memon["metadata"]["BPM"])],
|
||||
beat_zero_offset=SecondsTime(-memon["metadata"]["offset"]),
|
||||
)
|
||||
charts: MultiDict[Chart] = MultiDict()
|
||||
for memon_chart in memon["data"]:
|
||||
charts.add(
|
||||
memon_chart["dif_name"],
|
||||
Chart(
|
||||
level=memon_chart["level"],
|
||||
notes=[
|
||||
_load_memon_note_v0(note, memon_chart["resolution"])
|
||||
for note in memon_chart["notes"]
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
return Song(metadata=metadata, charts=charts, global_timing=global_timing)
|
||||
|
||||
|
||||
def load_memon_0_1_0(file: Path) -> Song:
|
||||
raw_memon = _load_raw_memon(file)
|
||||
schema = Memon_0_1_0()
|
||||
memon = schema.load(raw_memon)
|
||||
metadata = Metadata(
|
||||
**{key: memon["metadata"][key] for key in ["title", "artist", "audio", "cover"]}
|
||||
)
|
||||
global_timing = Timing(
|
||||
events=[BPMEvent(time=BeatsTime(0), BPM=memon["metadata"]["BPM"])],
|
||||
beat_zero_offset=SecondsTime(-memon["metadata"]["offset"]),
|
||||
)
|
||||
charts: MultiDict[Chart] = MultiDict()
|
||||
for difficulty, memon_chart in memon["data"].items():
|
||||
charts.add(
|
||||
difficulty,
|
||||
Chart(
|
||||
level=memon_chart["level"],
|
||||
notes=[
|
||||
_load_memon_note_v0(note, memon_chart["resolution"])
|
||||
for note in memon_chart["notes"]
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
return Song(metadata=metadata, charts=charts, global_timing=global_timing)
|
||||
|
||||
|
||||
def load_memon_0_2_0(file: Path) -> Song:
|
||||
raw_memon = _load_raw_memon(file)
|
||||
schema = Memon_0_2_0()
|
||||
memon = schema.load(raw_memon)
|
||||
metadata_dict = {
|
||||
key: memon["metadata"][key] for key in ["title", "artist", "audio", "cover"]
|
||||
}
|
||||
preview = None
|
||||
if "preview" in memon["metadata"]:
|
||||
start = memon["metadata"]["preview"]["position"]
|
||||
length = memon["metadata"]["preview"]["length"]
|
||||
metadata_dict["preview"] = Preview(start, length)
|
||||
|
||||
metadata = Metadata(**metadata_dict)
|
||||
global_timing = Timing(
|
||||
events=[BPMEvent(time=BeatsTime(0), BPM=memon["metadata"]["BPM"])],
|
||||
beat_zero_offset=SecondsTime(-memon["metadata"]["offset"]),
|
||||
)
|
||||
charts: MultiDict[Chart] = MultiDict()
|
||||
for difficulty, memon_chart in memon["data"].items():
|
||||
charts.add(
|
||||
difficulty,
|
||||
Chart(
|
||||
level=memon_chart["level"],
|
||||
notes=[
|
||||
_load_memon_note_v0(note, memon_chart["resolution"])
|
||||
for note in memon_chart["notes"]
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
return Song(metadata=metadata, charts=charts, global_timing=global_timing)
|
||||
|
||||
|
||||
def _long_note_tail_value_v0(note: LongNote) -> int:
|
||||
dx = note.tail_tip.x - note.position.x
|
||||
dy = note.tail_tip.y - note.position.y
|
||||
try:
|
||||
return X_Y_OFFSET_TO_P_VALUE[dx, dy]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"memon cannot represent a long note with its tail starting ({dx}, {dy}) away from the note"
|
||||
) from None
|
||||
|
||||
|
||||
def _get_timing(song: Song) -> Timing:
|
||||
if song.global_timing is not None:
|
||||
return song.global_timing
|
||||
else:
|
||||
return next(
|
||||
chart.timing for chart in song.charts.values() if chart.timing is not None
|
||||
)
|
||||
|
||||
|
||||
def _raise_if_unfit_for_v0(song: Song, version: str) -> None:
|
||||
|
||||
"""Raises an exception if the Song object is ill-formed or contains information
|
||||
that cannot be represented in a memon v0.x.y file (includes legacy)"""
|
||||
|
||||
if song.global_timing is None and all(
|
||||
chart.timing is None for chart in song.charts.values()
|
||||
):
|
||||
raise ValueError("The song has no timing information")
|
||||
|
||||
chart_timings = [
|
||||
chart.timing for chart in song.charts.values() if chart.timing is not None
|
||||
]
|
||||
|
||||
if chart_timings:
|
||||
first_one = chart_timings[0]
|
||||
if any(t != first_one for t in chart_timings):
|
||||
raise ValueError(
|
||||
f"memon:{version} cannot represent a song with per-chart timing"
|
||||
)
|
||||
|
||||
timing = _get_timing(song)
|
||||
number_of_timing_events = len(timing.events)
|
||||
if number_of_timing_events != 1:
|
||||
if number_of_timing_events == 0:
|
||||
raise ValueError("The song has no BPM")
|
||||
else:
|
||||
raise ValueError(f"memon:{version} does not handle BPM changes")
|
||||
|
||||
event = timing.events[0]
|
||||
if event.BPM <= 0:
|
||||
raise ValueError(f"memon:{version} only accepts strictly positive BPMs")
|
||||
|
||||
if event.time != 0:
|
||||
raise ValueError(f"memon:{version} only accepts a BPM on the first beat")
|
||||
|
||||
for difficulty, chart in song.charts.items():
|
||||
if len(set(chart.notes)) != len(chart.notes):
|
||||
raise ValueError(
|
||||
f"{difficulty} chart has duplicate notes, these cannot be represented"
|
||||
)
|
||||
|
||||
|
||||
def _dump_to_json(memon: dict) -> bytes:
|
||||
memon_fp = StringIO()
|
||||
json.dump(memon, memon_fp, use_decimal=True, indent=4)
|
||||
return memon_fp.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _compute_resolution(notes: List[Union[TapNote, LongNote]]) -> int:
|
||||
return lcm(
|
||||
*chain(
|
||||
iter(note.time.denominator for note in notes),
|
||||
iter(
|
||||
note.duration.denominator
|
||||
for note in notes
|
||||
if isinstance(note, LongNote)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _dump_memon_note_v0(
|
||||
note: Union[TapNote, LongNote], resolution: int
|
||||
) -> Dict[str, int]:
|
||||
"""converts a note into the {n, t, l, p} form"""
|
||||
memon_note = {
|
||||
"n": note.position.index,
|
||||
"t": note.time.numerator * (resolution // note.time.denominator),
|
||||
"l": 0,
|
||||
"p": 0,
|
||||
}
|
||||
if isinstance(note, LongNote):
|
||||
memon_note["l"] = note.duration.numerator * (
|
||||
resolution // note.duration.denominator
|
||||
)
|
||||
memon_note["p"] = _long_note_tail_value_v0(note)
|
||||
|
||||
return memon_note
|
||||
|
||||
|
||||
def dump_memon_legacy(song: Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]:
|
||||
|
||||
_raise_if_unfit_for_v0(song, "legacy")
|
||||
timing = _get_timing(song)
|
||||
|
||||
memon: Dict[str, Any] = {
|
||||
"metadata": {
|
||||
"song title": song.metadata.title,
|
||||
"artist": song.metadata.artist,
|
||||
"music path": str(song.metadata.audio),
|
||||
"jacket path": str(song.metadata.cover),
|
||||
"BPM": timing.events[0].BPM,
|
||||
"offset": -timing.beat_zero_offset,
|
||||
},
|
||||
"data": [],
|
||||
}
|
||||
for difficulty, chart in song.charts.items():
|
||||
resolution = _compute_resolution(chart.notes)
|
||||
memon["data"].append(
|
||||
{
|
||||
"dif_name": difficulty,
|
||||
"level": chart.level,
|
||||
"resolution": resolution,
|
||||
"notes": [
|
||||
_dump_memon_note_v0(note, resolution)
|
||||
for note in sorted(
|
||||
set(chart.notes), key=lambda n: (n.time, n.position)
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if path.is_dir():
|
||||
filepath = path / f"{song.metadata.title}.memon"
|
||||
else:
|
||||
filepath = path
|
||||
|
||||
return {filepath: _dump_to_json(memon)}
|
||||
|
||||
|
||||
def dump_memon_0_1_0(song: Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]:
|
||||
|
||||
_raise_if_unfit_for_v0(song, "v0.1.0")
|
||||
timing = _get_timing(song)
|
||||
|
||||
memon: Dict[str, Any] = {
|
||||
"version": "0.1.0",
|
||||
"metadata": {
|
||||
"song title": song.metadata.title,
|
||||
"artist": song.metadata.artist,
|
||||
"music path": str(song.metadata.audio),
|
||||
"album cover path": str(song.metadata.cover),
|
||||
"BPM": timing.events[0].BPM,
|
||||
"offset": -timing.beat_zero_offset,
|
||||
},
|
||||
"data": dict(),
|
||||
}
|
||||
for difficulty, chart in song.charts.items():
|
||||
resolution = _compute_resolution(chart.notes)
|
||||
memon["data"][difficulty] = {
|
||||
"level": chart.level,
|
||||
"resolution": resolution,
|
||||
"notes": [
|
||||
_dump_memon_note_v0(note, resolution)
|
||||
for note in sorted(set(chart.notes), key=lambda n: (n.time, n.position))
|
||||
],
|
||||
}
|
||||
|
||||
if path.is_dir():
|
||||
filepath = path / f"{song.metadata.title}.memon"
|
||||
else:
|
||||
filepath = path
|
||||
|
||||
return {filepath: _dump_to_json(memon)}
|
||||
|
||||
|
||||
def dump_memon_0_2_0(song: Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]:
|
||||
|
||||
_raise_if_unfit_for_v0(song, "v0.2.0")
|
||||
timing = _get_timing(song)
|
||||
|
||||
memon: Dict[str, Any] = {
|
||||
"version": "0.2.0",
|
||||
"metadata": {
|
||||
"song title": song.metadata.title,
|
||||
"artist": song.metadata.artist,
|
||||
"music path": str(song.metadata.audio),
|
||||
"album cover path": str(song.metadata.cover),
|
||||
"BPM": timing.events[0].BPM,
|
||||
"offset": -timing.beat_zero_offset,
|
||||
},
|
||||
"data": {},
|
||||
}
|
||||
|
||||
if song.metadata.preview is not None:
|
||||
memon["metadata"]["preview"] = {
|
||||
"position": song.metadata.preview.start,
|
||||
"length": song.metadata.preview.length,
|
||||
}
|
||||
|
||||
for difficulty, chart in song.charts.items():
|
||||
resolution = _compute_resolution(chart.notes)
|
||||
memon["data"][difficulty] = {
|
||||
"level": chart.level,
|
||||
"resolution": resolution,
|
||||
"notes": [
|
||||
_dump_memon_note_v0(note, resolution)
|
||||
for note in sorted(set(chart.notes), key=lambda n: (n.time, n.position))
|
||||
],
|
||||
}
|
||||
|
||||
if path.is_dir():
|
||||
filepath = path / f"{song.metadata.title}.memon"
|
||||
else:
|
||||
filepath = path
|
||||
|
||||
return {filepath: _dump_to_json(memon)}
|
||||
|
462
jubeatools/formats/memon/memon.py
Normal file
462
jubeatools/formats/memon/memon.py
Normal file
@ -0,0 +1,462 @@
|
||||
from io import StringIO
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import IO, Any, Dict, Iterable, List, Mapping, Tuple, Union
|
||||
|
||||
import simplejson as json
|
||||
from marshmallow import (
|
||||
RAISE,
|
||||
Schema,
|
||||
ValidationError,
|
||||
fields,
|
||||
post_load,
|
||||
validate,
|
||||
validates_schema,
|
||||
)
|
||||
|
||||
from jubeatools.song import *
|
||||
from jubeatools.utils import lcm
|
||||
|
||||
# v0.x.x long note value :
|
||||
#
|
||||
# 8
|
||||
# 4
|
||||
# 0
|
||||
# 11 7 3 . 1 5 9
|
||||
# 2
|
||||
# 6
|
||||
# 10
|
||||
|
||||
X_Y_OFFSET_TO_P_VALUE = {
|
||||
(0, -1): 0,
|
||||
(0, -2): 4,
|
||||
(0, -3): 8,
|
||||
(0, 1): 2,
|
||||
(0, 2): 6,
|
||||
(0, 3): 10,
|
||||
(1, 0): 1,
|
||||
(2, 0): 5,
|
||||
(3, 0): 9,
|
||||
(-1, 0): 3,
|
||||
(-2, 0): 7,
|
||||
(-3, 0): 11,
|
||||
}
|
||||
|
||||
P_VALUE_TO_X_Y_OFFSET = {v: k for k, v in X_Y_OFFSET_TO_P_VALUE.items()}
|
||||
|
||||
|
||||
class StrictSchema(Schema):
|
||||
class Meta:
|
||||
unknown = RAISE
|
||||
|
||||
|
||||
class MemonNote(StrictSchema):
|
||||
n = fields.Integer(required=True, validate=validate.Range(min=0, max=15))
|
||||
t = fields.Integer(required=True, validate=validate.Range(min=0))
|
||||
l = fields.Integer(required=True, validate=validate.Range(min=0))
|
||||
p = fields.Integer(required=True, validate=validate.Range(min=0, max=11))
|
||||
|
||||
@validates_schema
|
||||
def validate_tail_tip_position(self, data: Dict[str, int], **kwargs: Any) -> None:
|
||||
if data["l"] > 0:
|
||||
x = data["n"] % 4
|
||||
y = data["n"] // 4
|
||||
dx, dy = P_VALUE_TO_X_Y_OFFSET[data["p"]]
|
||||
if not (0 <= x + dx < 4 and 0 <= y + dy < 4):
|
||||
raise ValidationError("Invalid tail position : {data}")
|
||||
|
||||
|
||||
class MemonChart_0_1_0(StrictSchema):
|
||||
level = fields.Decimal(required=True)
|
||||
resolution = fields.Integer(required=True, validate=validate.Range(min=1))
|
||||
notes = fields.Nested(MemonNote, many=True)
|
||||
|
||||
|
||||
class MemonChart_legacy(MemonChart_0_1_0):
|
||||
dif_name = fields.String(required=True)
|
||||
|
||||
|
||||
class MemonMetadata_legacy(StrictSchema):
|
||||
title = fields.String(required=True, data_key="song title")
|
||||
artist = fields.String(required=True)
|
||||
audio = fields.String(required=True, data_key="music path")
|
||||
cover = fields.String(required=True, data_key="jacket path")
|
||||
BPM = fields.Decimal(
|
||||
required=True, validate=validate.Range(min=0, min_inclusive=False)
|
||||
)
|
||||
offset = fields.Decimal(required=True)
|
||||
|
||||
|
||||
class MemonMetadata_0_1_0(MemonMetadata_legacy):
|
||||
cover = fields.String(required=True, data_key="album cover path")
|
||||
|
||||
|
||||
class MemonPreview(StrictSchema):
|
||||
position = fields.Decimal(required=True, validate=validate.Range(min=0))
|
||||
length = fields.Decimal(
|
||||
required=True, validate=validate.Range(min=0, min_inclusive=False)
|
||||
)
|
||||
|
||||
|
||||
class MemonMetadata_0_2_0(MemonMetadata_0_1_0):
|
||||
preview = fields.Nested(MemonPreview)
|
||||
|
||||
|
||||
class Memon_legacy(StrictSchema):
|
||||
metadata = fields.Nested(MemonMetadata_legacy, required=True)
|
||||
data = fields.Nested(MemonChart_legacy, required=True, many=True)
|
||||
|
||||
|
||||
class Memon_0_1_0(StrictSchema):
|
||||
version = fields.String(required=True, validate=validate.OneOf(["0.1.0"]))
|
||||
metadata = fields.Nested(MemonMetadata_0_1_0, required=True)
|
||||
data = fields.Dict(
|
||||
keys=fields.String(), values=fields.Nested(MemonChart_0_1_0), required=True
|
||||
)
|
||||
|
||||
|
||||
class Memon_0_2_0(StrictSchema):
|
||||
version = fields.String(required=True, validate=validate.OneOf(["0.2.0"]))
|
||||
metadata = fields.Nested(MemonMetadata_0_2_0, required=True)
|
||||
data = fields.Dict(
|
||||
keys=fields.String(), values=fields.Nested(MemonChart_0_1_0), required=True
|
||||
)
|
||||
|
||||
|
||||
def _load_raw_memon(file: Path) -> Dict[str, Any]:
|
||||
with open(file) as f:
|
||||
res = json.load(f, use_decimal=True)
|
||||
if not isinstance(res, dict):
|
||||
raise ValueError(
|
||||
"JSON file does not represent a valid memon file : "
|
||||
"The top level of a memon file should be a JSON Object"
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
def _load_memon_note_v0(note: dict, resolution: int) -> Union[TapNote, LongNote]:
|
||||
position = NotePosition.from_index(note["n"])
|
||||
time = beats_time_from_ticks(ticks=note["t"], resolution=resolution)
|
||||
if note["l"] > 0:
|
||||
duration = beats_time_from_ticks(ticks=note["l"], resolution=resolution)
|
||||
tail_tip = position + NotePosition(*P_VALUE_TO_X_Y_OFFSET[note["p"]])
|
||||
return LongNote(time, position, duration, tail_tip)
|
||||
else:
|
||||
return TapNote(time, position)
|
||||
|
||||
|
||||
def load_memon_legacy(file: Path) -> Song:
|
||||
raw_memon = _load_raw_memon(file)
|
||||
schema = Memon_legacy()
|
||||
memon = schema.load(raw_memon)
|
||||
metadata = Metadata(
|
||||
title=memon["metadata"]["title"],
|
||||
artist=memon["metadata"]["artist"],
|
||||
audio=Path(memon["metadata"]["audio"]),
|
||||
cover=Path(memon["metadata"]["cover"]),
|
||||
)
|
||||
global_timing = Timing(
|
||||
events=[BPMEvent(time=BeatsTime(0), BPM=memon["metadata"]["BPM"])],
|
||||
beat_zero_offset=SecondsTime(-memon["metadata"]["offset"]),
|
||||
)
|
||||
charts: MultiDict[Chart] = MultiDict()
|
||||
for memon_chart in memon["data"]:
|
||||
charts.add(
|
||||
memon_chart["dif_name"],
|
||||
Chart(
|
||||
level=memon_chart["level"],
|
||||
notes=[
|
||||
_load_memon_note_v0(note, memon_chart["resolution"])
|
||||
for note in memon_chart["notes"]
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
return Song(metadata=metadata, charts=charts, global_timing=global_timing)
|
||||
|
||||
|
||||
def load_memon_0_1_0(file: Path) -> Song:
|
||||
raw_memon = _load_raw_memon(file)
|
||||
schema = Memon_0_1_0()
|
||||
memon = schema.load(raw_memon)
|
||||
metadata = Metadata(
|
||||
title=memon["metadata"]["title"],
|
||||
artist=memon["metadata"]["artist"],
|
||||
audio=Path(memon["metadata"]["audio"]),
|
||||
cover=Path(memon["metadata"]["cover"]),
|
||||
)
|
||||
global_timing = Timing(
|
||||
events=[BPMEvent(time=BeatsTime(0), BPM=memon["metadata"]["BPM"])],
|
||||
beat_zero_offset=SecondsTime(-memon["metadata"]["offset"]),
|
||||
)
|
||||
charts: MultiDict[Chart] = MultiDict()
|
||||
for difficulty, memon_chart in memon["data"].items():
|
||||
charts.add(
|
||||
difficulty,
|
||||
Chart(
|
||||
level=memon_chart["level"],
|
||||
notes=[
|
||||
_load_memon_note_v0(note, memon_chart["resolution"])
|
||||
for note in memon_chart["notes"]
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
return Song(metadata=metadata, charts=charts, global_timing=global_timing)
|
||||
|
||||
|
||||
def load_memon_0_2_0(file: Path) -> Song:
|
||||
raw_memon = _load_raw_memon(file)
|
||||
schema = Memon_0_2_0()
|
||||
memon = schema.load(raw_memon)
|
||||
preview = None
|
||||
if "preview" in memon["metadata"]:
|
||||
start = memon["metadata"]["preview"]["position"]
|
||||
length = memon["metadata"]["preview"]["length"]
|
||||
preview = Preview(start, length)
|
||||
|
||||
metadata = Metadata(
|
||||
title=memon["metadata"]["title"],
|
||||
artist=memon["metadata"]["artist"],
|
||||
audio=Path(memon["metadata"]["audio"]),
|
||||
cover=Path(memon["metadata"]["cover"]),
|
||||
preview=preview,
|
||||
)
|
||||
global_timing = Timing(
|
||||
events=[BPMEvent(time=BeatsTime(0), BPM=memon["metadata"]["BPM"])],
|
||||
beat_zero_offset=SecondsTime(-memon["metadata"]["offset"]),
|
||||
)
|
||||
charts: MultiDict[Chart] = MultiDict()
|
||||
for difficulty, memon_chart in memon["data"].items():
|
||||
charts.add(
|
||||
difficulty,
|
||||
Chart(
|
||||
level=memon_chart["level"],
|
||||
notes=[
|
||||
_load_memon_note_v0(note, memon_chart["resolution"])
|
||||
for note in memon_chart["notes"]
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
return Song(metadata=metadata, charts=charts, global_timing=global_timing)
|
||||
|
||||
|
||||
def _long_note_tail_value_v0(note: LongNote) -> int:
|
||||
dx = note.tail_tip.x - note.position.x
|
||||
dy = note.tail_tip.y - note.position.y
|
||||
try:
|
||||
return X_Y_OFFSET_TO_P_VALUE[dx, dy]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"memon cannot represent a long note with its tail starting ({dx}, {dy}) away from the note"
|
||||
) from None
|
||||
|
||||
|
||||
def _get_timing(song: Song) -> Timing:
|
||||
if song.global_timing is not None:
|
||||
return song.global_timing
|
||||
else:
|
||||
return next(
|
||||
chart.timing for chart in song.charts.values() if chart.timing is not None
|
||||
)
|
||||
|
||||
|
||||
def _raise_if_unfit_for_v0(song: Song, version: str) -> None:
|
||||
|
||||
"""Raises an exception if the Song object is ill-formed or contains information
|
||||
that cannot be represented in a memon v0.x.y file (includes legacy)"""
|
||||
|
||||
if song.global_timing is None and all(
|
||||
chart.timing is None for chart in song.charts.values()
|
||||
):
|
||||
raise ValueError("The song has no timing information")
|
||||
|
||||
chart_timings = [
|
||||
chart.timing for chart in song.charts.values() if chart.timing is not None
|
||||
]
|
||||
|
||||
if chart_timings:
|
||||
first_one = chart_timings[0]
|
||||
if any(t != first_one for t in chart_timings):
|
||||
raise ValueError(
|
||||
f"memon:{version} cannot represent a song with per-chart timing"
|
||||
)
|
||||
|
||||
timing = _get_timing(song)
|
||||
number_of_timing_events = len(timing.events)
|
||||
if number_of_timing_events != 1:
|
||||
if number_of_timing_events == 0:
|
||||
raise ValueError("The song has no BPM")
|
||||
else:
|
||||
raise ValueError(f"memon:{version} does not handle BPM changes")
|
||||
|
||||
event = timing.events[0]
|
||||
if event.BPM <= 0:
|
||||
raise ValueError(f"memon:{version} only accepts strictly positive BPMs")
|
||||
|
||||
if event.time != 0:
|
||||
raise ValueError(f"memon:{version} only accepts a BPM on the first beat")
|
||||
|
||||
for difficulty, chart in song.charts.items():
|
||||
if len(set(chart.notes)) != len(chart.notes):
|
||||
raise ValueError(
|
||||
f"{difficulty} chart has duplicate notes, these cannot be represented"
|
||||
)
|
||||
|
||||
|
||||
def _dump_to_json(memon: dict) -> bytes:
|
||||
memon_fp = StringIO()
|
||||
json.dump(memon, memon_fp, use_decimal=True, indent=4)
|
||||
return memon_fp.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _compute_resolution(notes: List[Union[TapNote, LongNote]]) -> int:
|
||||
return lcm(
|
||||
*chain(
|
||||
iter(note.time.denominator for note in notes),
|
||||
iter(
|
||||
note.duration.denominator
|
||||
for note in notes
|
||||
if isinstance(note, LongNote)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _dump_memon_note_v0(
|
||||
note: Union[TapNote, LongNote], resolution: int
|
||||
) -> Dict[str, int]:
|
||||
"""converts a note into the {n, t, l, p} form"""
|
||||
memon_note = {
|
||||
"n": note.position.index,
|
||||
"t": note.time.numerator * (resolution // note.time.denominator),
|
||||
"l": 0,
|
||||
"p": 0,
|
||||
}
|
||||
if isinstance(note, LongNote):
|
||||
memon_note["l"] = note.duration.numerator * (
|
||||
resolution // note.duration.denominator
|
||||
)
|
||||
memon_note["p"] = _long_note_tail_value_v0(note)
|
||||
|
||||
return memon_note
|
||||
|
||||
|
||||
def dump_memon_legacy(song: Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]:
|
||||
|
||||
_raise_if_unfit_for_v0(song, "legacy")
|
||||
timing = _get_timing(song)
|
||||
|
||||
memon: Dict[str, Any] = {
|
||||
"metadata": {
|
||||
"song title": song.metadata.title,
|
||||
"artist": song.metadata.artist,
|
||||
"music path": str(song.metadata.audio),
|
||||
"jacket path": str(song.metadata.cover),
|
||||
"BPM": timing.events[0].BPM,
|
||||
"offset": -timing.beat_zero_offset,
|
||||
},
|
||||
"data": [],
|
||||
}
|
||||
for difficulty, chart in song.charts.items():
|
||||
resolution = _compute_resolution(chart.notes)
|
||||
memon["data"].append(
|
||||
{
|
||||
"dif_name": difficulty,
|
||||
"level": chart.level,
|
||||
"resolution": resolution,
|
||||
"notes": [
|
||||
_dump_memon_note_v0(note, resolution)
|
||||
for note in sorted(
|
||||
set(chart.notes), key=lambda n: (n.time, n.position)
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if path.is_dir():
|
||||
filepath = path / f"{song.metadata.title}.memon"
|
||||
else:
|
||||
filepath = path
|
||||
|
||||
return {filepath: _dump_to_json(memon)}
|
||||
|
||||
|
||||
def dump_memon_0_1_0(song: Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]:
|
||||
|
||||
_raise_if_unfit_for_v0(song, "v0.1.0")
|
||||
timing = _get_timing(song)
|
||||
|
||||
memon: Dict[str, Any] = {
|
||||
"version": "0.1.0",
|
||||
"metadata": {
|
||||
"song title": song.metadata.title,
|
||||
"artist": song.metadata.artist,
|
||||
"music path": str(song.metadata.audio),
|
||||
"album cover path": str(song.metadata.cover),
|
||||
"BPM": timing.events[0].BPM,
|
||||
"offset": -timing.beat_zero_offset,
|
||||
},
|
||||
"data": dict(),
|
||||
}
|
||||
for difficulty, chart in song.charts.items():
|
||||
resolution = _compute_resolution(chart.notes)
|
||||
memon["data"][difficulty] = {
|
||||
"level": chart.level,
|
||||
"resolution": resolution,
|
||||
"notes": [
|
||||
_dump_memon_note_v0(note, resolution)
|
||||
for note in sorted(set(chart.notes), key=lambda n: (n.time, n.position))
|
||||
],
|
||||
}
|
||||
|
||||
if path.is_dir():
|
||||
filepath = path / f"{song.metadata.title}.memon"
|
||||
else:
|
||||
filepath = path
|
||||
|
||||
return {filepath: _dump_to_json(memon)}
|
||||
|
||||
|
||||
def dump_memon_0_2_0(song: Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]:
|
||||
|
||||
_raise_if_unfit_for_v0(song, "v0.2.0")
|
||||
timing = _get_timing(song)
|
||||
|
||||
memon: Dict[str, Any] = {
|
||||
"version": "0.2.0",
|
||||
"metadata": {
|
||||
"song title": song.metadata.title,
|
||||
"artist": song.metadata.artist,
|
||||
"music path": str(song.metadata.audio),
|
||||
"album cover path": str(song.metadata.cover),
|
||||
"BPM": timing.events[0].BPM,
|
||||
"offset": -timing.beat_zero_offset,
|
||||
},
|
||||
"data": {},
|
||||
}
|
||||
|
||||
if song.metadata.preview is not None:
|
||||
memon["metadata"]["preview"] = {
|
||||
"position": song.metadata.preview.start,
|
||||
"length": song.metadata.preview.length,
|
||||
}
|
||||
|
||||
for difficulty, chart in song.charts.items():
|
||||
resolution = _compute_resolution(chart.notes)
|
||||
memon["data"][difficulty] = {
|
||||
"level": chart.level,
|
||||
"resolution": resolution,
|
||||
"notes": [
|
||||
_dump_memon_note_v0(note, resolution)
|
||||
for note in sorted(set(chart.notes), key=lambda n: (n.time, n.position))
|
||||
],
|
||||
}
|
||||
|
||||
if path.is_dir():
|
||||
filepath = path / f"{song.metadata.title}.memon"
|
||||
else:
|
||||
filepath = path
|
||||
|
||||
return {filepath: _dump_to_json(memon)}
|
@ -32,14 +32,15 @@ def dump_and_load(
|
||||
file.seek(0)
|
||||
actual_song = load_function(Path(file.name))
|
||||
|
||||
assert expected_song == actual_song
|
||||
assert actual_song == expected_song
|
||||
|
||||
|
||||
@st.composite
|
||||
def memon_legacy_compatible_song(draw: DrawFunc) -> Song:
|
||||
"""Memon versions below v0.2.0 do not support preview metadata"""
|
||||
"""Memon versions below v0.2.0 do not support any preview metadata"""
|
||||
song: Song = draw(song_strat(TimingOption.GLOBAL, True, NoteOption.LONGS))
|
||||
song.metadata.preview = None
|
||||
song.metadata.preview_file = None
|
||||
return song
|
||||
|
||||
|
||||
@ -58,7 +59,10 @@ def test_memon_0_1_0(song: Song) -> None:
|
||||
|
||||
@st.composite
|
||||
def memon_0_2_0_compatible_song(draw: DrawFunc) -> Song:
|
||||
return draw(song_strat(TimingOption.GLOBAL, True, NoteOption.LONGS)) # type: ignore
|
||||
"""Memon v0.2.0 does not support preview_file"""
|
||||
song: Song = draw(song_strat(TimingOption.GLOBAL, True, NoteOption.LONGS))
|
||||
song.metadata.preview_file = None
|
||||
return song
|
||||
|
||||
|
||||
@given(memon_0_2_0_compatible_song())
|
||||
|
@ -2,6 +2,7 @@ import unicodedata
|
||||
from functools import reduce
|
||||
from math import gcd
|
||||
from typing import Callable, Iterable, Optional, TypeVar
|
||||
from numbers import Number
|
||||
|
||||
|
||||
def single_lcm(a: int, b: int) -> int:
|
||||
|
Loading…
Reference in New Issue
Block a user