1
0
mirror of synced 2025-01-19 08:17:24 +01:00

#MEMO2 SUPPORT LET4Z GOOOOOOO!!1!1

This commit is contained in:
Stepland 2020-07-19 17:40:53 +02:00
parent 8e47e2d8e1
commit 990b06056a
11 changed files with 883 additions and 13 deletions

View File

@ -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列形式) | ✔️ | ✔️ |

View File

@ -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:

View File

@ -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")

View File

@ -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:

View File

@ -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,

View File

@ -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)

View 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

View 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):
...

View 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)

View 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

View File

@ -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)}"