#MEMO2 SUPPORT LET4Z GOOOOOOO!!1!1
This commit is contained in:
parent
8e47e2d8e1
commit
990b06056a
@ -2,7 +2,7 @@
|
||||
A toolbox for jubeat file formats
|
||||
|
||||
## Conversion
|
||||
jubeatools can handle conversions in the following way :
|
||||
jubeatools supports the following formats :
|
||||
|
||||
### Memon
|
||||
| | input | output |
|
||||
@ -14,6 +14,7 @@ jubeatools can handle conversions in the following way :
|
||||
### Jubeat Analyser
|
||||
| | input | output |
|
||||
|----------------------|:-----:|:------:|
|
||||
| #memo2 | ✔️ | ✔️ |
|
||||
| #memo1 | ✔️ | ✔️ |
|
||||
| #memo | ✔️ | ✔️ |
|
||||
| mono-column (1列形式) | ✔️ | ✔️ |
|
@ -42,7 +42,7 @@ command_grammar = Grammar(
|
||||
value = value_in_quotes / number
|
||||
value_in_quotes = '"' quoted_value '"'
|
||||
quoted_value = ~r"[^\"]*"
|
||||
number = ~r"\d+(\.\d+)?"
|
||||
number = ~r"-?\d+(\.\d+)?"
|
||||
ws = ~r"[\t ]*"
|
||||
comment = ~r"//.*"
|
||||
"""
|
||||
@ -99,7 +99,7 @@ def parse_command(line: str) -> Tuple[str, str]:
|
||||
raise
|
||||
|
||||
|
||||
def dump_command(key: str, value: Any) -> str:
|
||||
def dump_command(key: str, value: Any = None) -> str:
|
||||
if len(key) == 1:
|
||||
key_part = key
|
||||
else:
|
||||
|
@ -273,9 +273,18 @@ class JubeatAnalyserParser:
|
||||
method(value)
|
||||
else:
|
||||
method()
|
||||
|
||||
def do_b(self, value):
|
||||
self.beats_per_section = Decimal(value)
|
||||
|
||||
def do_m(self, value):
|
||||
self.music = value
|
||||
|
||||
def do_o(self, value):
|
||||
self.offset = int(value)
|
||||
|
||||
def do_r(self, value):
|
||||
self.offset += int(value)
|
||||
|
||||
def do_t(self, value):
|
||||
self.current_tempo = Decimal(value)
|
||||
@ -283,12 +292,6 @@ class JubeatAnalyserParser:
|
||||
BPMEvent(self.section_starting_beat, BPM=self.current_tempo)
|
||||
)
|
||||
|
||||
def do_o(self, value):
|
||||
self.offset = int(value)
|
||||
|
||||
def do_b(self, value):
|
||||
self.beats_per_section = Decimal(value)
|
||||
|
||||
def do_pw(self, value):
|
||||
if int(value) != 4:
|
||||
raise ValueError("jubeatools only supports 4×4 charts")
|
||||
|
@ -179,7 +179,7 @@ class MemoParser(JubeatAnalyserParser):
|
||||
memo_chart_line = parse_double_column_chart_line(line)
|
||||
self.append_chart_line(memo_chart_line)
|
||||
else:
|
||||
raise SyntaxError(f"not a valid mono-column file line : {line}")
|
||||
raise SyntaxError(f"not a valid memo file line : {line}")
|
||||
|
||||
def notes(self) -> Iterator[Union[TapNote, LongNote]]:
|
||||
if self.hold_by_arrow:
|
||||
|
@ -242,7 +242,7 @@ def _dump_memo1_chart(
|
||||
def _dump_memo1_internal(song: Song, circle_free: bool = False) -> List[JubeatFile]:
|
||||
files: List[JubeatFile] = []
|
||||
for difficulty, chart in song.charts.items():
|
||||
contents = _dump_memo_chart(
|
||||
contents = _dump_memo1_chart(
|
||||
difficulty,
|
||||
chart,
|
||||
song.metadata,
|
||||
|
@ -169,7 +169,7 @@ class Memo1Parser(JubeatAnalyserParser):
|
||||
memo_chart_line = parse_double_column_chart_line(line)
|
||||
self.append_chart_line(memo_chart_line)
|
||||
else:
|
||||
raise SyntaxError(f"not a valid mono-column file line : {line}")
|
||||
raise SyntaxError(f"not a valid memo1 file line : {line}")
|
||||
|
||||
def notes(self) -> Iterator[Union[TapNote, LongNote]]:
|
||||
if self.hold_by_arrow:
|
||||
@ -327,5 +327,5 @@ def _load_memo1_file(lines: List[str]) -> Song:
|
||||
|
||||
def load_memo1(path: Path) -> Song:
|
||||
files = load_files(path)
|
||||
charts = [_load_memo_file(lines) for _, lines in files.items()]
|
||||
charts = [_load_memo1_file(lines) for _, lines in files.items()]
|
||||
return reduce(Song.merge, charts)
|
||||
|
12
jubeatools/formats/jubeat_analyser/memo2/__init__.py
Normal file
12
jubeatools/formats/jubeat_analyser/memo2/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""
|
||||
memo2
|
||||
|
||||
memo2 is the jubeat analyser version of youbeat files :
|
||||
|
||||
http://yosh52.web.fc2.com/jubeat/fumenformat.html
|
||||
|
||||
A chart in this format needs to have a `#memo2` line somewhere to indicate its format
|
||||
"""
|
||||
|
||||
from .dump import dump_memo2
|
||||
from .load import load_memo2
|
340
jubeatools/formats/jubeat_analyser/memo2/dump.py
Normal file
340
jubeatools/formats/jubeat_analyser/memo2/dump.py
Normal file
@ -0,0 +1,340 @@
|
||||
from collections import ChainMap, defaultdict
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from functools import partial
|
||||
from io import StringIO
|
||||
from itertools import chain, zip_longest
|
||||
from math import ceil
|
||||
from typing import Dict, Iterator, List, Optional, Set, Tuple, Union
|
||||
|
||||
from more_itertools import chunked, collapse, intersperse, mark_ends, windowed
|
||||
from path import Path
|
||||
from sortedcontainers import SortedKeyList
|
||||
|
||||
from jubeatools import __version__
|
||||
from jubeatools.formats.filetypes import ChartFile, JubeatFile
|
||||
from jubeatools.song import (
|
||||
BPMEvent,
|
||||
SecondsTime,
|
||||
BeatsTime,
|
||||
Chart,
|
||||
LongNote,
|
||||
Metadata,
|
||||
NotePosition,
|
||||
Song,
|
||||
TapNote,
|
||||
Timing,
|
||||
)
|
||||
from jubeatools.utils import lcm
|
||||
|
||||
from ..command import dump_command
|
||||
from ..dump_tools import (
|
||||
BEATS_TIME_TO_SYMBOL,
|
||||
COMMAND_ORDER,
|
||||
DEFAULT_EXTRA_SYMBOLS,
|
||||
DIFFICULTIES,
|
||||
DIRECTION_TO_ARROW,
|
||||
DIRECTION_TO_LINE,
|
||||
NOTE_TO_CIRCLE_FREE_SYMBOL,
|
||||
JubeatAnalyserDumpedSection,
|
||||
LongNoteEnd,
|
||||
SortedDefaultDict,
|
||||
create_sections_from_chart,
|
||||
fraction_to_decimal,
|
||||
)
|
||||
from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
|
||||
|
||||
AnyNote = Union[TapNote, LongNote, LongNoteEnd]
|
||||
|
||||
EMPTY_BEAT_SYMBOL = "-" # U+0FF0D : FULLWIDTH HYPHEN-MINUS
|
||||
EMPTY_POSITION_SYMBOL = "□" # U+025A1 : WHITE SQUARE
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
positions: Dict[NotePosition, str] = field(default_factory=dict)
|
||||
bars: Dict[int, List[str]] = field(default_factory=dict)
|
||||
|
||||
def dump(self) -> Iterator[str]:
|
||||
# Check that bars are contiguous
|
||||
for a, b in windowed(sorted(self.bars), 2):
|
||||
if b is not None and b - a != 1:
|
||||
raise ValueError("Frame has discontinuous bars")
|
||||
|
||||
for pos, bar in zip_longest(self.dump_positions(), self.dump_bars()):
|
||||
if bar is None:
|
||||
yield pos
|
||||
else:
|
||||
yield f"{pos} {bar}"
|
||||
|
||||
def dump_positions(self) -> Iterator[str]:
|
||||
for y in range(4):
|
||||
yield "".join(
|
||||
self.positions.get(NotePosition(x, y), EMPTY_POSITION_SYMBOL)
|
||||
for x in range(4)
|
||||
)
|
||||
|
||||
def dump_bars(self) -> Iterator[str]:
|
||||
for i in range(4):
|
||||
if i in self.bars:
|
||||
yield f"|{''.join(self.bars[i])}|"
|
||||
else:
|
||||
yield ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StopEvent:
|
||||
time: BeatsTime
|
||||
duration: SecondsTime
|
||||
|
||||
|
||||
@dataclass
|
||||
class Memo2Section:
|
||||
"""A 4-beat-long group of notes"""
|
||||
notes: List[AnyNote] = field(default_factory=list)
|
||||
events: List[Union[BPMEvent, StopEvent]] = field(default_factory=list)
|
||||
|
||||
def render(self, circle_free: bool = False) -> str:
|
||||
return "\n".join(self._dump_notes(circle_free))
|
||||
|
||||
def _dump_notes(self, circle_free: bool = False) -> Iterator[str]:
|
||||
# Split notes and events into bars
|
||||
notes_by_bar: Dict[int, List[AnyNote]] = defaultdict(list)
|
||||
for note in self.notes:
|
||||
time_in_section = note.time % BeatsTime(4)
|
||||
bar_index = int(time_in_section)
|
||||
notes_by_bar[bar_index].append(note)
|
||||
events_by_bar: Dict[int, List[Union[BPMEvent, StopEvent]]] = defaultdict(list)
|
||||
for event in self.events:
|
||||
time_in_section = event.time % BeatsTime(4)
|
||||
bar_index = int(time_in_section)
|
||||
events_by_bar[bar_index].append(event)
|
||||
|
||||
# Pre-render timing bars
|
||||
bars: Dict[int, List[str]] = defaultdict(dict)
|
||||
chosen_symbols: Dict[BeatsTime, str] = {}
|
||||
symbols_iterator = iter(NOTE_SYMBOLS)
|
||||
for bar_index in range(4):
|
||||
notes = notes_by_bar.get(bar_index, [])
|
||||
events = events_by_bar.get(bar_index, [])
|
||||
bar_length = lcm(
|
||||
*(note.time.denominator for note in notes),
|
||||
*(event.time.denominator for event in events)
|
||||
)
|
||||
if bar_length < 3:
|
||||
bar_length = 4
|
||||
bar_dict: Dict[int, List[Union[str, BPMEvent, StopEvent]]] = defaultdict(list)
|
||||
for note in notes:
|
||||
time_in_section = note.time % BeatsTime(4)
|
||||
time_in_bar = note.time % Fraction(1)
|
||||
time_index = time_in_bar.numerator * (
|
||||
bar_length / time_in_bar.denominator
|
||||
)
|
||||
if time_index not in bar_dict:
|
||||
symbol = next(symbols_iterator)
|
||||
chosen_symbols[time_in_section] = symbol
|
||||
bar_dict[time_index].append(symbol)
|
||||
for event in events:
|
||||
time_in_bar = event.time % Fraction(1)
|
||||
time_index = time_in_bar.numerator * (
|
||||
bar_length / time_in_bar.denominator
|
||||
)
|
||||
bar_dict[time_index].append(event)
|
||||
bar = []
|
||||
for i in range(bar_length):
|
||||
events_and_note = bar_dict.get(i, [])
|
||||
stops = list(filter(lambda e: isinstance(e, StopEvent), events_and_note))
|
||||
bpms = list(filter(lambda e: isinstance(e, BPMEvent), events_and_note))
|
||||
notes = list(filter(lambda e: isinstance(e, str), events_and_note))
|
||||
assert len(notes) <= 1
|
||||
for stop in stops:
|
||||
bar.append(f"[{int(stop.duration * 1000)}]")
|
||||
|
||||
for bpm in bpms:
|
||||
bar.append(f"({bpm.BPM})")
|
||||
|
||||
if notes:
|
||||
note = notes[0]
|
||||
else:
|
||||
note = EMPTY_BEAT_SYMBOL
|
||||
|
||||
bar.append(note)
|
||||
|
||||
bars[bar_index] = bar
|
||||
|
||||
# Create frame by bar
|
||||
frames_by_bar: Dict[int, List[Frame]] = defaultdict(list)
|
||||
for bar_index in range(4):
|
||||
bar = bars.get(bar_index, [])
|
||||
frame = Frame()
|
||||
frame.bars[bar_index] = bar
|
||||
for note in notes_by_bar[bar_index]:
|
||||
time_in_section = note.time % BeatsTime(4)
|
||||
symbol = chosen_symbols[time_in_section]
|
||||
if isinstance(note, TapNote):
|
||||
if note.position in frame.positions:
|
||||
frames_by_bar[bar_index].append(frame)
|
||||
frame = Frame()
|
||||
frame.positions[note.position] = symbol
|
||||
elif isinstance(note, LongNote):
|
||||
needed_positions = set(note.positions_covered())
|
||||
if needed_positions & frame.positions.keys():
|
||||
frames_by_bar[bar_index].append(frame)
|
||||
frame = Frame()
|
||||
direction = note.tail_direction()
|
||||
arrow = DIRECTION_TO_ARROW[direction]
|
||||
line = DIRECTION_TO_LINE[direction]
|
||||
for is_first, is_last, pos in mark_ends(note.positions_covered()):
|
||||
if is_first:
|
||||
frame.positions[pos] = symbol
|
||||
elif is_last:
|
||||
frame.positions[pos] = arrow
|
||||
else:
|
||||
frame.positions[pos] = line
|
||||
elif isinstance(note, LongNoteEnd):
|
||||
if note.position in frame.positions:
|
||||
frames_by_bar[bar_index].append(frame)
|
||||
frame = Frame()
|
||||
if circle_free and symbol in NOTE_TO_CIRCLE_FREE_SYMBOL:
|
||||
symbol = NOTE_TO_CIRCLE_FREE_SYMBOL[symbol]
|
||||
frame.positions[note.position] = symbol
|
||||
frames_by_bar[bar_index].append(frame)
|
||||
|
||||
# Merge bar-specific frames is possible
|
||||
final_frames: List[Frame] = []
|
||||
for bar_index in range(4):
|
||||
frames = frames_by_bar[bar_index]
|
||||
# Merge if :
|
||||
# - No split in current bar (only one frame)
|
||||
# - There is a previous frame
|
||||
# - The previous frame is not a split frame (it holds a bar)
|
||||
# - The previous and current bars are all in the same 4-bar group
|
||||
# - The note positions in the previous frame do not clash with the current frame
|
||||
if (
|
||||
len(frames) == 1
|
||||
and final_frames
|
||||
and final_frames[-1].bars
|
||||
and max(final_frames[-1].bars.keys()) // 4
|
||||
== min(frames[0].bars.keys()) // 4
|
||||
and (
|
||||
not (final_frames[-1].positions.keys() & frames[0].positions.keys())
|
||||
)
|
||||
):
|
||||
final_frames[-1].bars.update(frames[0].bars)
|
||||
final_frames[-1].positions.update(frames[0].positions)
|
||||
else:
|
||||
final_frames.extend(frames)
|
||||
|
||||
dumped_frames = map(lambda f: f.dump(), final_frames)
|
||||
yield from collapse(intersperse("", dumped_frames))
|
||||
|
||||
def _raise_if_unfit_for_memo2(chart: Chart, timing: Timing, circle_free: bool = False):
|
||||
if len(timing.events) < 1:
|
||||
raise ValueError("No BPM found in file") from None
|
||||
|
||||
first_bpm = min(timing.events, key=lambda e: e.time)
|
||||
if first_bpm.time != 0:
|
||||
raise ValueError("First BPM event does not happen on beat zero")
|
||||
|
||||
if any(
|
||||
not note.tail_is_straight()
|
||||
for note in chart.notes
|
||||
if isinstance(note, LongNote)
|
||||
):
|
||||
raise ValueError(
|
||||
"Chart contains diagonal long notes, reprensenting these in"
|
||||
" memo format is not supported by jubeatools"
|
||||
)
|
||||
|
||||
|
||||
def _dump_memo2_chart(
|
||||
difficulty: str,
|
||||
chart: Chart,
|
||||
metadata: Metadata,
|
||||
timing: Timing,
|
||||
circle_free: bool = False,
|
||||
) -> StringIO:
|
||||
|
||||
_raise_if_unfit_for_memo2(chart, timing, circle_free)
|
||||
|
||||
sections = SortedDefaultDict(Memo2Section)
|
||||
|
||||
timing_events = sorted(timing.events, key=lambda e: e.time)
|
||||
notes = SortedKeyList(set(chart.notes), key=lambda n: n.time)
|
||||
|
||||
for note in chart.notes:
|
||||
if isinstance(note, LongNote):
|
||||
notes.add(LongNoteEnd(note.time + note.duration, note.position))
|
||||
|
||||
all_events = SortedKeyList(timing_events + notes, key=lambda n: n.time)
|
||||
last_event = all_events[-1]
|
||||
last_measure = last_event.time // 4
|
||||
for i in range(last_measure + 1):
|
||||
beat = BeatsTime(4) * i
|
||||
sections.add_key(beat)
|
||||
|
||||
# Timing events
|
||||
sections[0].events.append(StopEvent(BeatsTime(0), timing.beat_zero_offset))
|
||||
for event in timing_events:
|
||||
section_index = event.time // 4
|
||||
sections[section_index].events.append(event)
|
||||
|
||||
# Fill sections with notes
|
||||
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
|
||||
sections[key].notes = list(
|
||||
notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False))
|
||||
)
|
||||
|
||||
# Actual output to file
|
||||
file = StringIO()
|
||||
file.write(f"// Converted using jubeatools {__version__}\n")
|
||||
file.write(f"// https://github.com/Stepland/jubeatools\n\n")
|
||||
|
||||
# Header
|
||||
file.write(dump_command("lev", int(chart.level)) + "\n")
|
||||
file.write(dump_command("dif", DIFFICULTIES.get(difficulty, 1)) + "\n")
|
||||
if metadata.audio:
|
||||
file.write(dump_command("m", metadata.audio) + "\n")
|
||||
if metadata.title:
|
||||
file.write(dump_command("title", metadata.title) + "\n")
|
||||
if metadata.artist:
|
||||
file.write(dump_command("artist", metadata.artist) + "\n")
|
||||
if metadata.cover:
|
||||
file.write(dump_command("jacket", metadata.cover) + "\n")
|
||||
if metadata.preview is not None:
|
||||
file.write(dump_command("prevpos", int(metadata.preview.start * 1000)) + "\n")
|
||||
|
||||
if any(isinstance(note, LongNote) for note in chart.notes):
|
||||
file.write(dump_command("holdbyarrow", 1) + "\n")
|
||||
|
||||
if circle_free:
|
||||
file.write(dump_command("circlefree", 1) + "\n")
|
||||
|
||||
file.write(dump_command("memo2") + "\n")
|
||||
|
||||
# Notes
|
||||
for _, section in sections.items():
|
||||
file.write(section.render(circle_free) + "\n")
|
||||
|
||||
return file
|
||||
|
||||
|
||||
def _dump_memo2_internal(song: Song, circle_free: bool = False) -> List[JubeatFile]:
|
||||
files: List[JubeatFile] = []
|
||||
for difficulty, chart in song.charts.items():
|
||||
contents = _dump_memo2_chart(
|
||||
difficulty,
|
||||
chart,
|
||||
song.metadata,
|
||||
chart.timing or song.global_timing,
|
||||
circle_free,
|
||||
)
|
||||
files.append(ChartFile(contents, song, difficulty, chart))
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def dump_memo2(song: Song, circle_free: bool, folder: Path, name_pattern: str = None):
|
||||
...
|
469
jubeatools/formats/jubeat_analyser/memo2/load.py
Normal file
469
jubeatools/formats/jubeat_analyser/memo2/load.py
Normal file
@ -0,0 +1,469 @@
|
||||
import warnings
|
||||
from collections import ChainMap
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from functools import reduce
|
||||
from itertools import chain, product, zip_longest
|
||||
from typing import Dict, Iterator, List, Mapping, Optional, Set, Tuple, Union
|
||||
|
||||
import constraint
|
||||
from more_itertools import collapse, mark_ends
|
||||
from parsimonious import Grammar, NodeVisitor, ParseError
|
||||
from path import Path
|
||||
|
||||
from jubeatools.song import (
|
||||
BeatsTime,
|
||||
BPMEvent,
|
||||
Chart,
|
||||
LongNote,
|
||||
Metadata,
|
||||
NotePosition,
|
||||
SecondsTime,
|
||||
Song,
|
||||
TapNote,
|
||||
Timing,
|
||||
)
|
||||
|
||||
from ..command import is_command, parse_command
|
||||
from ..files import load_files
|
||||
from ..load_tools import (
|
||||
CIRCLE_FREE_TO_DECIMAL_TIME,
|
||||
CIRCLE_FREE_TO_NOTE_SYMBOL,
|
||||
EMPTY_BEAT_SYMBOLS,
|
||||
LONG_ARROWS,
|
||||
LONG_DIRECTION,
|
||||
JubeatAnalyserParser,
|
||||
UnfinishedLongNote,
|
||||
decimal_to_beats,
|
||||
find_long_note_candidates,
|
||||
is_double_column_chart_line,
|
||||
is_empty_line,
|
||||
is_simple_solution,
|
||||
long_note_solution_heuristic,
|
||||
parse_double_column_chart_line,
|
||||
pick_correct_long_note_candidates,
|
||||
split_double_byte_line,
|
||||
)
|
||||
from ..symbol_definition import is_symbol_definition, parse_symbol_definition
|
||||
from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notes:
|
||||
string: str
|
||||
|
||||
def dump(self) -> str:
|
||||
return self.string
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stop:
|
||||
duration: int
|
||||
|
||||
def dump(self) -> str:
|
||||
return f"[{self.duration}]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BPM:
|
||||
value: Decimal
|
||||
|
||||
def dump(self) -> str:
|
||||
return f"({self.value})"
|
||||
|
||||
|
||||
Event = Union[Notes, Stop, BPM]
|
||||
|
||||
@dataclass
|
||||
class RawMemo2ChartLine:
|
||||
position: str
|
||||
timing: Optional[List[Event]]
|
||||
|
||||
def __str__(self):
|
||||
if self.timing:
|
||||
return f"{self.position} |{''.join(e.dump() for e in self.timing)}|"
|
||||
else:
|
||||
return self.position
|
||||
|
||||
@dataclass
|
||||
class Memo2ChartLine:
|
||||
"""timing part only contains notes"""
|
||||
position: str
|
||||
timing: Optional[List[str]]
|
||||
|
||||
memo2_chart_line_grammar = Grammar(
|
||||
r"""
|
||||
line = ws position_part ws (timing_part ws)? comment?
|
||||
position_part = ~r"[^*#:|/\s]{4,8}"
|
||||
timing_part = "|" event+ "|"
|
||||
event = stop / bpm / notes
|
||||
stop = "[" pos_integer "]"
|
||||
pos_integer = ~r"\d+"
|
||||
bpm = "(" float ")"
|
||||
float = ~r"\d+(\.\d+)?"
|
||||
notes = ~r"[^*#:\(\[|/\s]+"
|
||||
ws = ~r"[\t ]*"
|
||||
comment = ~r"//.*"
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class Memo2ChartLineVisitor(NodeVisitor):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pos_part = None
|
||||
self.time_part = []
|
||||
|
||||
def visit_line(self, node, visited_children):
|
||||
if not self.time_part:
|
||||
self.time_part = None
|
||||
return RawMemo2ChartLine(self.pos_part, self.time_part)
|
||||
|
||||
def visit_position_part(self, node, visited_children):
|
||||
self.pos_part = node.text
|
||||
|
||||
def visit_stop(self, node, visited_children):
|
||||
_, duration, _ = node.children
|
||||
self.time_part.append(Stop(int(duration.text)))
|
||||
|
||||
def visit_bpm(self, node, visited_children):
|
||||
_, value, _ = node.children
|
||||
self.time_part.append(BPM(Decimal(value.text)))
|
||||
|
||||
def visit_notes(self, node, visited_children):
|
||||
self.time_part.append(Notes(node.text))
|
||||
|
||||
def generic_visit(self, node, visited_children):
|
||||
...
|
||||
|
||||
|
||||
def is_memo2_chart_line(line: str) -> bool:
|
||||
try:
|
||||
memo2_chart_line_grammar.parse(line)
|
||||
except ParseError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def parse_memo2_chart_line(line: str) -> RawMemo2ChartLine:
|
||||
return Memo2ChartLineVisitor().visit(
|
||||
memo2_chart_line_grammar.parse(line)
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class Memo2Frame:
|
||||
position_part: List[List[str]]
|
||||
timing_part: List[List[str]]
|
||||
|
||||
@property
|
||||
def duration(self) -> BeatsTime:
|
||||
return BeatsTime(len(self.timing_part))
|
||||
|
||||
def __str__(self):
|
||||
res = []
|
||||
for pos, time in zip_longest(self.position_part, self.timing_part):
|
||||
line = [f"{''.join(pos)}"]
|
||||
if time is not None:
|
||||
line += [f"|{''.join(time)}|"]
|
||||
res += [" ".join(line)]
|
||||
return "\n".join(res)
|
||||
|
||||
|
||||
class Memo2Parser(JubeatAnalyserParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.offset = None
|
||||
self.current_beat = BeatsTime(0)
|
||||
self.frames: List[Memo2Frame] = []
|
||||
|
||||
def do_b(self, value):
|
||||
raise ValueError(
|
||||
"beat command (b=...) found, this commands cannot be used in #memo2 files"
|
||||
)
|
||||
|
||||
def do_t(self, value):
|
||||
if self.frames:
|
||||
raise ValueError(
|
||||
"tempo command (t=...) found outside of the file header, "
|
||||
"this should not happen in #memo2 files"
|
||||
)
|
||||
else:
|
||||
self.timing_events.append(
|
||||
BPMEvent(self.current_beat, BPM=Decimal(value))
|
||||
)
|
||||
|
||||
def do_r(self, value):
|
||||
if self.frames:
|
||||
raise ValueError(
|
||||
"offset increase command (r=...) found outside of the file "
|
||||
"header, this is not supported by jubeatools"
|
||||
)
|
||||
elif self.offset is None:
|
||||
super().do_o(value)
|
||||
else:
|
||||
super().do_r(value)
|
||||
|
||||
def do_memo(self):
|
||||
raise ValueError("#memo command found : This is not a memo2 file")
|
||||
|
||||
def do_memo1(self):
|
||||
raise ValueError("#memo1 command found : This is not a memo2 file")
|
||||
|
||||
def do_boogie(self):
|
||||
self.do_bpp(1)
|
||||
|
||||
def do_memo2(self):
|
||||
...
|
||||
|
||||
def do_bpp(self, value):
|
||||
if self.frames:
|
||||
raise ValueError("jubeatools does not handle changes of #bpp halfway")
|
||||
else:
|
||||
self._do_bpp(value)
|
||||
|
||||
def append_chart_line(self, raw_line: RawMemo2ChartLine):
|
||||
if len(raw_line.position.encode("shift_jis_2004")) != 4 * self.bytes_per_panel:
|
||||
raise SyntaxError(
|
||||
f"Invalid chart line for #bpp={self.bytes_per_panel} : {raw_line}"
|
||||
)
|
||||
if raw_line.timing is not None and self.bytes_per_panel == 2:
|
||||
if any(
|
||||
len(event.string.encode("shift_jis_2004")) % 2 != 0
|
||||
for event in raw_line.timing
|
||||
if isinstance(event, Notes)
|
||||
):
|
||||
raise SyntaxError(f"Invalid chart line for #bpp=2 : {raw_line}")
|
||||
|
||||
if not raw_line.timing:
|
||||
line = Memo2ChartLine(raw_line.position, None)
|
||||
else:
|
||||
# split notes
|
||||
bar = []
|
||||
for event in raw_line.timing:
|
||||
if isinstance(event, Notes):
|
||||
bar.extend(self._split_chart_line(event.string))
|
||||
else:
|
||||
bar.append(event)
|
||||
# extract timing info
|
||||
bar_length = sum(1 for e in bar if isinstance(e, str))
|
||||
symbol_duration = BeatsTime(1, bar_length)
|
||||
in_bar_beat = BeatsTime(0)
|
||||
for event in bar:
|
||||
if isinstance(event, str):
|
||||
in_bar_beat += symbol_duration
|
||||
elif isinstance(event, BPM):
|
||||
self.timing_events.append(
|
||||
BPMEvent(
|
||||
time=self.current_beat+in_bar_beat,
|
||||
BPM=event.value
|
||||
)
|
||||
)
|
||||
elif isinstance(event, Stop):
|
||||
time = self.current_beat+in_bar_beat
|
||||
if time != 0:
|
||||
raise ValueError(
|
||||
"Chart contains a pause that's not happening at the "
|
||||
"very first beat, these are not supported by jubeatools"
|
||||
)
|
||||
if self.offset is None:
|
||||
self.offset = event.duration
|
||||
else:
|
||||
# This could happen if several pauses exist at the first
|
||||
# beat of the chart or if both an in-bar pause and an
|
||||
# o=... command exist
|
||||
self.offset += event.duration
|
||||
|
||||
bar_notes = [e for e in bar if isinstance(e, str)]
|
||||
line = Memo2ChartLine(raw_line.position, bar_notes)
|
||||
|
||||
self.current_chart_lines.append(line)
|
||||
if len(self.current_chart_lines) == 4:
|
||||
self._push_frame()
|
||||
|
||||
def _split_chart_line(self, line: str) -> List[str]:
|
||||
if self.bytes_per_panel == 2:
|
||||
return split_double_byte_line(line)
|
||||
else:
|
||||
return list(line)
|
||||
|
||||
def _frames_duration(self) -> Decimal:
|
||||
return sum(frame.duration for frame in self.frames)
|
||||
|
||||
def _push_frame(self):
|
||||
position_part = [
|
||||
self._split_chart_line(memo_line.position)
|
||||
for memo_line in self.current_chart_lines
|
||||
]
|
||||
timing_part = [
|
||||
memo_line.timing
|
||||
for memo_line in self.current_chart_lines
|
||||
if memo_line.timing is not None
|
||||
]
|
||||
frame = Memo2Frame(position_part, timing_part)
|
||||
self.frames.append(frame)
|
||||
self.current_chart_lines = []
|
||||
|
||||
def finish_last_few_notes(self):
|
||||
"""Call this once when the end of the file is reached,
|
||||
flushes the chart line and chart frame buffers to create the last chart
|
||||
section"""
|
||||
if self.current_chart_lines:
|
||||
if len(self.current_chart_lines) != 4:
|
||||
raise SyntaxError(
|
||||
f"Unfinished chart frame when flushing : \n"
|
||||
f"{self.current_chart_lines}"
|
||||
)
|
||||
self._push_frame()
|
||||
|
||||
def load_line(self, raw_line: str):
|
||||
line = raw_line.strip()
|
||||
if is_command(line):
|
||||
command, value = parse_command(line)
|
||||
self.handle_command(command, value)
|
||||
elif is_empty_line(line) or self.is_short_line(line):
|
||||
return
|
||||
elif is_memo2_chart_line(line):
|
||||
memo_chart_line = parse_memo2_chart_line(line)
|
||||
self.append_chart_line(memo_chart_line)
|
||||
else:
|
||||
raise SyntaxError(f"not a valid memo2 file line : {line}")
|
||||
|
||||
def notes(self) -> Iterator[Union[TapNote, LongNote]]:
|
||||
if self.hold_by_arrow:
|
||||
yield from self._iter_notes()
|
||||
else:
|
||||
yield from self._iter_notes_without_longs()
|
||||
|
||||
def _iter_frames(
|
||||
self,
|
||||
) -> Iterator[
|
||||
Tuple[Mapping[str, BeatsTime], Memo2Frame, BeatsTime]
|
||||
]:
|
||||
"""iterate over tuples of (currently_defined_symbols, frame)"""
|
||||
local_symbols: Dict[str, Decimal] = {}
|
||||
frame_starting_beat = BeatsTime(0)
|
||||
for i, frame in enumerate(self.frames):
|
||||
if frame.timing_part:
|
||||
frame_starting_beat = sum(f.duration for f in self.frames[:i])
|
||||
local_symbols = {
|
||||
symbol: frame_starting_beat + bar_index + BeatsTime(symbol_index, len(bar))
|
||||
for bar_index, bar in enumerate(frame.timing_part)
|
||||
for symbol_index, symbol in enumerate(bar)
|
||||
if symbol not in EMPTY_BEAT_SYMBOLS
|
||||
}
|
||||
yield local_symbols, frame
|
||||
|
||||
def _iter_notes(self) -> Iterator[Union[TapNote, LongNote]]:
|
||||
unfinished_longs: Dict[NotePosition, UnfinishedLongNote] = {}
|
||||
for currently_defined_symbols, frame in self._iter_frames():
|
||||
should_skip: Set[NotePosition] = set()
|
||||
# 1/3 : look for ends to unfinished long notes
|
||||
for pos, unfinished_long in unfinished_longs.items():
|
||||
x, y = pos.as_tuple()
|
||||
symbol = frame.position_part[y][x]
|
||||
if self.circle_free and symbol in CIRCLE_FREE_SYMBOLS:
|
||||
circled_symbol = CIRCLE_FREE_TO_NOTE_SYMBOL[symbol]
|
||||
try:
|
||||
symbol_time = currently_defined_symbols[circled_symbol]
|
||||
except KeyError:
|
||||
raise SyntaxError(
|
||||
"Chart section positional part constains the circle free "
|
||||
f"symbol '{symbol}' but the associated circled symbol "
|
||||
f"'{circled_symbol}' could not be found in the timing part:\n"
|
||||
f"{frame}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
should_skip.add(pos)
|
||||
yield unfinished_long.ends_at(symbol_time)
|
||||
|
||||
unfinished_longs = {
|
||||
k: unfinished_longs[k] for k in unfinished_longs.keys() - should_skip
|
||||
}
|
||||
|
||||
# 2/3 : look for new long notes starting on this bloc
|
||||
arrow_to_note_candidates = find_long_note_candidates(
|
||||
frame.position_part, currently_defined_symbols.keys(), should_skip
|
||||
)
|
||||
if arrow_to_note_candidates:
|
||||
solution = pick_correct_long_note_candidates(
|
||||
arrow_to_note_candidates, frame.position_part,
|
||||
)
|
||||
for arrow_pos, note_pos in solution.items():
|
||||
should_skip.add(arrow_pos)
|
||||
should_skip.add(note_pos)
|
||||
symbol = frame.position_part[note_pos.y][note_pos.x]
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
unfinished_longs[note_pos] = UnfinishedLongNote(
|
||||
time=symbol_time, position=note_pos, tail_tip=arrow_pos
|
||||
)
|
||||
|
||||
# 3/3 : find regular notes
|
||||
for y, x in product(range(4), range(4)):
|
||||
position = NotePosition(x, y)
|
||||
if position in should_skip:
|
||||
continue
|
||||
symbol = frame.position_part[y][x]
|
||||
try:
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
except KeyError:
|
||||
continue
|
||||
yield TapNote(symbol_time, position)
|
||||
|
||||
def _iter_notes_without_longs(self) -> Iterator[TapNote]:
|
||||
for currently_defined_symbols, frame in self._iter_frames():
|
||||
# cross compare symbols with the position information
|
||||
for y, x in product(range(4), range(4)):
|
||||
symbol = frame.position_part[y][x]
|
||||
try:
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
except KeyError:
|
||||
continue
|
||||
position = NotePosition(x, y)
|
||||
yield TapNote(symbol_time, position)
|
||||
|
||||
|
||||
def _load_memo2_file(lines: List[str]) -> Song:
|
||||
parser = Memo2Parser()
|
||||
for i, raw_line in enumerate(lines):
|
||||
try:
|
||||
parser.load_line(raw_line)
|
||||
except Exception as e:
|
||||
raise SyntaxError(
|
||||
f"Error while parsing memo line {i} :\n" f"{type(e).__name__}: {e}"
|
||||
) from None
|
||||
|
||||
parser.finish_last_few_notes()
|
||||
metadata = Metadata(
|
||||
title=parser.title,
|
||||
artist=parser.artist,
|
||||
audio=parser.music,
|
||||
cover=parser.jacket,
|
||||
)
|
||||
if parser.preview_start is not None:
|
||||
metadata.preview_start = SecondsTime(parser.preview_start) / 1000
|
||||
metadata.preview_length = SecondsTime(10)
|
||||
|
||||
timing = Timing(
|
||||
events=parser.timing_events, beat_zero_offset=SecondsTime(parser.offset) / 1000
|
||||
)
|
||||
charts = {
|
||||
parser.difficulty: Chart(
|
||||
level=parser.level,
|
||||
timing=timing,
|
||||
notes=sorted(parser.notes(), key=lambda n: (n.time, n.position)),
|
||||
)
|
||||
}
|
||||
return Song(metadata=metadata, charts=charts)
|
||||
|
||||
|
||||
def load_memo2(path: Path) -> Song:
|
||||
files = load_files(path)
|
||||
charts = [_load_memo2_file(lines) for _, lines in files.items()]
|
||||
return reduce(Song.merge, charts)
|
39
jubeatools/formats/jubeat_analyser/tests/test_memo2.py
Normal file
39
jubeatools/formats/jubeat_analyser/tests/test_memo2.py
Normal file
@ -0,0 +1,39 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from hypothesis import given
|
||||
|
||||
from jubeatools.song import (
|
||||
BeatsTime,
|
||||
BPMEvent,
|
||||
Chart,
|
||||
LongNote,
|
||||
Metadata,
|
||||
NotePosition,
|
||||
SecondsTime,
|
||||
TapNote,
|
||||
Timing,
|
||||
)
|
||||
from jubeatools.testutils.strategies import NoteOption
|
||||
from jubeatools.testutils.strategies import notes as notes_strat
|
||||
|
||||
from ..memo2.dump import _dump_memo2_chart
|
||||
from ..memo2.load import Memo2Parser
|
||||
|
||||
|
||||
@given(notes_strat(NoteOption.LONGS))
|
||||
def test_many_notes(notes):
|
||||
timing = Timing(
|
||||
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
|
||||
)
|
||||
chart = Chart(
|
||||
level=0, timing=timing, notes=sorted(notes, key=lambda n: (n.time, n.position))
|
||||
)
|
||||
metadata = Metadata("", "", "", "")
|
||||
string_io = _dump_memo2_chart("", chart, metadata, timing)
|
||||
chart = string_io.getvalue()
|
||||
parser = Memo2Parser()
|
||||
for line in chart.split("\n"):
|
||||
parser.load_line(line)
|
||||
parser.finish_last_few_notes()
|
||||
actual = set(parser.notes())
|
||||
assert notes == actual
|
@ -1,3 +1,4 @@
|
||||
import unicodedata
|
||||
from functools import reduce
|
||||
from math import gcd
|
||||
|
||||
@ -10,3 +11,8 @@ def single_lcm(a: int, b: int):
|
||||
def lcm(*args):
|
||||
"""Return lcm of args."""
|
||||
return reduce(single_lcm, args, 1)
|
||||
|
||||
|
||||
def charinfo(c: str) -> str:
|
||||
"""Return some info on the character"""
|
||||
return f"{c!r} # U+{ord(c):05X} : {unicodedata.name(c)}"
|
Loading…
x
Reference in New Issue
Block a user