#memo1 SUPPORT LET'S FUCKING GOOOOOOO
This commit is contained in:
parent
e16329e05f
commit
96521a8011
@ -5,10 +5,11 @@ from collections import Counter
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from itertools import product
|
||||
from typing import Dict, Iterator, List, Set, Tuple
|
||||
from itertools import product, zip_longest
|
||||
from typing import Dict, Iterator, List, Optional, Set, Tuple
|
||||
|
||||
import constraint
|
||||
from parsimonious import Grammar, NodeVisitor, ParseError
|
||||
|
||||
from jubeatools.song import BeatsTime, BPMEvent, LongNote, NotePosition
|
||||
|
||||
@ -44,8 +45,81 @@ LONG_DIRECTION = {
|
||||
EMPTY_LINE = re.compile(r"\s*(//.*)?")
|
||||
|
||||
|
||||
# Any unicode character that's both :
|
||||
# - confusable with a dash/hyphen
|
||||
# - encodable in shift_jis_2004
|
||||
# Gets added to the list of characters to be ignored in the timing section
|
||||
EMPTY_BEAT_SYMBOLS = {
|
||||
"一", # U+4E00 - CJK UNIFIED IDEOGRAPH-4E00
|
||||
"-", # U+FF0D - FULLWIDTH HYPHEN-MINUS
|
||||
"ー", # U+30FC - KATAKANA-HIRAGANA PROLONGED SOUND MARK
|
||||
"─", # U+2500 - BOX DRAWINGS LIGHT HORIZONTAL
|
||||
"―", # U+2015 - HORIZONTAL BAR
|
||||
"━", # U+2501 - BOX DRAWINGS HEAVY HORIZONTAL
|
||||
"–", # U+2013 - EN DASH
|
||||
"‐", # U+2010 - HYPHEN
|
||||
"-", # U+002D - HYPHEN-MINUS
|
||||
"−", # U+2212 - MINUS SIGN
|
||||
}
|
||||
|
||||
|
||||
double_column_chart_line_grammar = Grammar(
|
||||
r"""
|
||||
line = ws position_part ws (timing_part ws)? comment?
|
||||
position_part = ~r"[^*#:|/\s]{4,8}"
|
||||
timing_part = "|" ~r"[^*#:|/\s]*" "|"
|
||||
ws = ~r"[\t ]*"
|
||||
comment = ~r"//.*"
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoubleColumnChartLine:
|
||||
position: str
|
||||
timing: Optional[str]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.position} |{self.timing}|"
|
||||
|
||||
|
||||
class DoubleColumnChartLineVisitor(NodeVisitor):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pos_part = None
|
||||
self.time_part = None
|
||||
|
||||
def visit_line(self, node, visited_children):
|
||||
return DoubleColumnChartLine(self.pos_part, self.time_part)
|
||||
|
||||
def visit_position_part(self, node, visited_children):
|
||||
self.pos_part = node.text
|
||||
|
||||
def visit_timing_part(self, node, visited_children):
|
||||
_, time_part, _ = node.children
|
||||
self.time_part = time_part.text
|
||||
|
||||
def generic_visit(self, node, visited_children):
|
||||
...
|
||||
|
||||
|
||||
def is_double_column_chart_line(line: str) -> bool:
|
||||
try:
|
||||
double_column_chart_line_grammar.parse(line)
|
||||
except ParseError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def parse_double_column_chart_line(line: str) -> DoubleColumnChartLine:
|
||||
return DoubleColumnChartLineVisitor().visit(
|
||||
double_column_chart_line_grammar.parse(line)
|
||||
)
|
||||
|
||||
|
||||
def is_empty_line(line: str) -> bool:
|
||||
return bool(EMPTY_LINE.match(line))
|
||||
return bool(EMPTY_LINE.fullmatch(line))
|
||||
|
||||
|
||||
def split_double_byte_line(line: str) -> List[str]:
|
||||
@ -123,12 +197,11 @@ def find_long_note_candidates(
|
||||
def pick_correct_long_note_candidates(
|
||||
arrow_to_note_candidates: Dict[NotePosition, Set[NotePosition]],
|
||||
bloc: List[List[str]],
|
||||
should_skip: Set[NotePosition],
|
||||
currently_defined_symbols: Dict[str, Decimal],
|
||||
section_starting_beat: Decimal,
|
||||
) -> Iterator[UnfinishedLongNote]:
|
||||
) -> Dict[NotePosition, NotePosition]:
|
||||
"""Believe it or not, assigning each arrow to a valid note candidate
|
||||
involves whipping out a CSP solver"""
|
||||
involves whipping out a CSP solver.
|
||||
Returns an arrow_pos -> note_pos mapping
|
||||
"""
|
||||
problem = constraint.Problem()
|
||||
for arrow_pos, note_candidates in arrow_to_note_candidates.items():
|
||||
problem.addVariable(arrow_pos, list(note_candidates))
|
||||
@ -149,13 +222,7 @@ def pick_correct_long_note_candidates(
|
||||
+ "\n"
|
||||
"The resulting long notes might not be what you expect"
|
||||
)
|
||||
for arrow_pos, note_pos in solution.items():
|
||||
should_skip.add(arrow_pos)
|
||||
should_skip.add(note_pos)
|
||||
symbol = bloc[note_pos.y][note_pos.x]
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
note_time = decimal_to_beats(section_starting_beat + symbol_time)
|
||||
yield UnfinishedLongNote(time=note_time, position=note_pos, tail_tip=arrow_pos)
|
||||
return solution
|
||||
|
||||
|
||||
def note_distance(a: NotePosition, b: NotePosition) -> float:
|
||||
@ -292,3 +359,18 @@ class JubeatAnalyserParser:
|
||||
|
||||
def is_short_line(self, line: str) -> bool:
|
||||
return len(line.encode("shift_jis_2004")) < self.bytes_per_panel * 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoubleColumnFrame:
|
||||
position_part: List[List[str]]
|
||||
timing_part: List[List[str]]
|
||||
|
||||
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)
|
||||
|
@ -28,79 +28,28 @@ 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,
|
||||
DoubleColumnChartLine,
|
||||
DoubleColumnFrame,
|
||||
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
|
||||
|
||||
memo_chart_line_grammar = Grammar(
|
||||
r"""
|
||||
line = ws position_part ws (timing_part ws)? comment?
|
||||
position_part = ~r"[^*#:|/\s]{4,8}"
|
||||
timing_part = "|" ~r"[^*#:|/\s]+" "|"
|
||||
ws = ~r"[\t ]*"
|
||||
comment = ~r"//.*"
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoChartLine:
|
||||
position: str
|
||||
timing: Optional[str]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.position} |{self.timing}|"
|
||||
|
||||
|
||||
class MemoChartLineVisitor(NodeVisitor):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pos_part = None
|
||||
self.time_part = None
|
||||
|
||||
def visit_line(self, node, visited_children):
|
||||
return MemoChartLine(self.pos_part, self.time_part)
|
||||
|
||||
def visit_position_part(self, node, visited_children):
|
||||
self.pos_part = node.text
|
||||
|
||||
def visit_timing_part(self, node, visited_children):
|
||||
_, time_part, _ = node.children
|
||||
self.time_part = time_part.text
|
||||
|
||||
def generic_visit(self, node, visited_children):
|
||||
...
|
||||
|
||||
|
||||
def is_memo_chart_line(line: str) -> bool:
|
||||
try:
|
||||
memo_chart_line_grammar.parse(line)
|
||||
except ParseError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def parse_memo_chart_line(line: str) -> MemoChartLine:
|
||||
return MemoChartLineVisitor().visit(memo_chart_line_grammar.parse(line))
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoFrame:
|
||||
position_part: List[List[str]]
|
||||
timing_part: List[List[str]]
|
||||
|
||||
class MemoFrame(DoubleColumnFrame):
|
||||
@property
|
||||
def duration(self) -> Decimal:
|
||||
res = 0
|
||||
@ -108,15 +57,6 @@ class MemoFrame:
|
||||
res += len(t)
|
||||
return Decimal("0.25") * res
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoLoadedSection:
|
||||
@ -138,31 +78,12 @@ class MemoLoadedSection:
|
||||
return "\n".join(res)
|
||||
|
||||
|
||||
# Any unicode character that's both :
|
||||
# - confusable with a dash/hyphen
|
||||
# - encodable in shift_jis_2004
|
||||
# Gets added to the list of characters to be ignored in the timing section
|
||||
EMPTY_BEAT_SYMBOLS = {
|
||||
"一", # U+4E00 - CJK UNIFIED IDEOGRAPH-4E00
|
||||
"-", # U+FF0D - FULLWIDTH HYPHEN-MINUS
|
||||
"ー", # U+30FC - KATAKANA-HIRAGANA PROLONGED SOUND MARK
|
||||
"─", # U+2500 - BOX DRAWINGS LIGHT HORIZONTAL
|
||||
"―", # U+2015 - HORIZONTAL BAR
|
||||
"━", # U+2501 - BOX DRAWINGS HEAVY HORIZONTAL
|
||||
"–", # U+2013 - EN DASH
|
||||
"‐", # U+2010 - HYPHEN
|
||||
"-", # U+002D - HYPHEN-MINUS
|
||||
"−", # U+2212 - MINUS SIGN
|
||||
}
|
||||
|
||||
|
||||
class MemoParser(JubeatAnalyserParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.symbols: Dict[str, Decimal] = {}
|
||||
self.frames: List[MemoFrame] = []
|
||||
self.sections: List[MemoLoadedSection] = []
|
||||
self.only_timingless_frames = False
|
||||
|
||||
def do_memo(self):
|
||||
...
|
||||
@ -178,7 +99,7 @@ class MemoParser(JubeatAnalyserParser):
|
||||
else:
|
||||
self._do_bpp(value)
|
||||
|
||||
def append_chart_line(self, line: MemoChartLine):
|
||||
def append_chart_line(self, line: DoubleColumnChartLine):
|
||||
if len(line.position.encode("shift_jis_2004")) != 4 * self.bytes_per_panel:
|
||||
raise SyntaxError(
|
||||
f"Invalid chart line for #bpp={self.bytes_per_panel} : {line}"
|
||||
@ -252,10 +173,12 @@ class MemoParser(JubeatAnalyserParser):
|
||||
elif is_symbol_definition(line):
|
||||
symbol, timing = parse_symbol_definition(line)
|
||||
self.define_symbol(symbol, timing)
|
||||
elif is_memo_chart_line(line):
|
||||
memo_chart_line = parse_memo_chart_line(line)
|
||||
elif is_empty_line(line) or self.is_short_line(line):
|
||||
return
|
||||
elif is_double_column_chart_line(line):
|
||||
memo_chart_line = parse_double_column_chart_line(line)
|
||||
self.append_chart_line(memo_chart_line)
|
||||
elif not (is_empty_line(line) or self.is_short_line(line)):
|
||||
else:
|
||||
raise SyntaxError(f"not a valid mono-column file line : {line}")
|
||||
|
||||
def notes(self) -> Iterator[Union[TapNote, LongNote]]:
|
||||
@ -329,18 +252,17 @@ class MemoParser(JubeatAnalyserParser):
|
||||
frame.position_part, currently_defined_symbols.keys(), should_skip
|
||||
)
|
||||
if arrow_to_note_candidates:
|
||||
unfinished_longs.update(
|
||||
{
|
||||
note.position: note
|
||||
for note in pick_correct_long_note_candidates(
|
||||
solution = pick_correct_long_note_candidates(
|
||||
arrow_to_note_candidates,
|
||||
frame.position_part,
|
||||
should_skip,
|
||||
currently_defined_symbols,
|
||||
section_starting_beat,
|
||||
)
|
||||
}
|
||||
)
|
||||
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]
|
||||
note_time = decimal_to_beats(section_starting_beat + symbol_time)
|
||||
unfinished_longs[note_pos] = UnfinishedLongNote(time=note_time, position=note_pos, tail_tip=arrow_pos)
|
||||
|
||||
# 3/3 : find regular notes
|
||||
for y, x in product(range(4), range(4)):
|
||||
|
16
jubeatools/formats/jubeat_analyser/memo1/__init__.py
Normal file
16
jubeatools/formats/jubeat_analyser/memo1/__init__.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""
|
||||
memo1
|
||||
|
||||
memo1 is an hybrid between youbeat and memo :
|
||||
|
||||
http://yosh52.web.fc2.com/jubeat/fumenformat.html
|
||||
|
||||
A chart in this format needs to have a `#memo1` line somewhere to indicate its format
|
||||
|
||||
It's very similar to memo, except it handles irregular timing bars which make
|
||||
mono-column-style symbol definitions obsolete. Unlike #memo2 or youbeat however,
|
||||
it does not handle in-bar bpm changes and pauses.
|
||||
"""
|
||||
|
||||
from .dump import dump_memo1
|
||||
from .load import load_memo1
|
256
jubeatools/formats/jubeat_analyser/memo1/dump.py
Normal file
256
jubeatools/formats/jubeat_analyser/memo1/dump.py
Normal file
@ -0,0 +1,256 @@
|
||||
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.utils import lcm
|
||||
from jubeatools.formats.filetypes import ChartFile, JubeatFile
|
||||
from jubeatools.song import (
|
||||
BeatsTime,
|
||||
Chart,
|
||||
LongNote,
|
||||
Metadata,
|
||||
NotePosition,
|
||||
Song,
|
||||
TapNote,
|
||||
Timing,
|
||||
)
|
||||
|
||||
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, length: Decimal) -> 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")
|
||||
# Check all bars are in the same 4-bar group
|
||||
if self.bars.keys() != set(bar % 4 for bar in self.bars):
|
||||
raise ValueError("Frame contains bars from different 4-bar groups")
|
||||
|
||||
for pos, bar in zip_longest(self.dump_positions(), self.dump_bars(length)):
|
||||
if bar is None:
|
||||
bar = ""
|
||||
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, length: Decimal) -> Iterator[str]:
|
||||
for i in range(ceil(length)):
|
||||
if i in self.bars:
|
||||
yield f"|{''.join(self.bars[i])}|"
|
||||
else:
|
||||
yield ""
|
||||
|
||||
|
||||
class Memo1DumpedSection(JubeatAnalyserDumpedSection):
|
||||
def render(self, circle_free: bool = False) -> str:
|
||||
blocs = []
|
||||
commands = list(self._dump_commands())
|
||||
if commands:
|
||||
blocs.append(commands)
|
||||
notes = list(self._dump_notes(circle_free))
|
||||
if notes:
|
||||
blocs.append(notes)
|
||||
return "\n".join(collapse(intersperse("", blocs)))
|
||||
|
||||
def _dump_notes(self, circle_free: bool = False) -> Iterator[str]:
|
||||
# Split notes into bars
|
||||
notes_by_bar: Dict[int, List[AnyNote]] = defaultdict(list)
|
||||
for note in self.notes:
|
||||
time_in_section = note.time - self.current_beat
|
||||
bar_index = int(time_in_section)
|
||||
notes_by_bar[bar_index].append(note)
|
||||
|
||||
# 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(ceil(self.length)):
|
||||
notes = notes_by_bar.get(bar_index, [])
|
||||
bar_length = lcm(*(note.time.denominator for note in notes))
|
||||
if bar_length < 3:
|
||||
bar_length = 4
|
||||
bar_dict: Dict[int, str] = {}
|
||||
for note in notes:
|
||||
time_in_section = note.time - self.current_beat
|
||||
time_in_bar = time_in_section % 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] = symbol
|
||||
bar = [bar_dict.get(i, EMPTY_BEAT_SYMBOL) for i in range(bar_length)]
|
||||
bars[bar_index] = bar
|
||||
|
||||
|
||||
# Create frame by bar
|
||||
frames_by_bar: Dict[int, List[Frame]] = defaultdict(list)
|
||||
for bar_index in range(ceil(self.length)):
|
||||
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 - self.current_beat
|
||||
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(ceil(self.length)):
|
||||
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(self.length), final_frames)
|
||||
yield from collapse(intersperse("", dumped_frames))
|
||||
|
||||
|
||||
def _raise_if_unfit_for_memo1(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_memo1_chart(
|
||||
difficulty: str,
|
||||
chart: Chart,
|
||||
metadata: Metadata,
|
||||
timing: Timing,
|
||||
circle_free: bool = False,
|
||||
) -> StringIO:
|
||||
|
||||
_raise_if_unfit_for_memo1(chart, timing, circle_free)
|
||||
|
||||
sections = create_sections_from_chart(
|
||||
Memo1DumpedSection, chart, difficulty, timing, metadata, circle_free
|
||||
)
|
||||
|
||||
# Jubeat Analyser format command
|
||||
sections[0].commands["memo1"] = None
|
||||
|
||||
# Actual output to file
|
||||
file = StringIO()
|
||||
file.write(f"// Converted using jubeatools {__version__}\n")
|
||||
file.write(f"// https://github.com/Stepland/jubeatools\n\n")
|
||||
for _, section in sections.items():
|
||||
file.write(section.render(circle_free) + "\n")
|
||||
|
||||
return file
|
||||
|
||||
|
||||
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(
|
||||
difficulty,
|
||||
chart,
|
||||
song.metadata,
|
||||
chart.timing or song.global_timing,
|
||||
circle_free,
|
||||
)
|
||||
files.append(ChartFile(contents, song, difficulty, chart))
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def dump_memo1(song: Song, circle_free: bool, folder: Path, name_pattern: str = None):
|
||||
...
|
326
jubeatools/formats/jubeat_analyser/memo1/load.py
Normal file
326
jubeatools/formats/jubeat_analyser/memo1/load.py
Normal file
@ -0,0 +1,326 @@
|
||||
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,
|
||||
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,
|
||||
DoubleColumnChartLine,
|
||||
DoubleColumnFrame,
|
||||
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
|
||||
|
||||
|
||||
class Memo1Frame(DoubleColumnFrame):
|
||||
@property
|
||||
def duration(self) -> Decimal:
|
||||
return Decimal(len(self.timing_part))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Memo1LoadedSection:
|
||||
frames: List[Memo1Frame]
|
||||
length: Decimal
|
||||
tempo: Decimal
|
||||
|
||||
def __str__(self):
|
||||
res = []
|
||||
if self.length != 4:
|
||||
res += [f"b={self.length}", ""]
|
||||
for _, is_last, frame in mark_ends(self.frames):
|
||||
res += [str(frame)]
|
||||
if not is_last:
|
||||
res += [""]
|
||||
return "\n".join(res)
|
||||
|
||||
|
||||
class Memo1Parser(JubeatAnalyserParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.frames: List[Memo1Frame] = []
|
||||
self.sections: List[Memo1LoadedSection] = []
|
||||
|
||||
def do_memo(self):
|
||||
raise ValueError("This is not a memo file")
|
||||
|
||||
def do_memo1(self):
|
||||
...
|
||||
|
||||
do_boogie = do_memo2 = do_memo
|
||||
|
||||
def do_bpp(self, value):
|
||||
if self.sections or self.frames:
|
||||
raise ValueError("jubeatools does not handle changes of #bpp halfway")
|
||||
else:
|
||||
self._do_bpp(value)
|
||||
|
||||
def append_chart_line(self, line: DoubleColumnChartLine):
|
||||
if len(line.position.encode("shift_jis_2004")) != 4 * self.bytes_per_panel:
|
||||
raise SyntaxError(
|
||||
f"Invalid chart line for #bpp={self.bytes_per_panel} : {line}"
|
||||
)
|
||||
if line.timing is not None and self.bytes_per_panel == 2:
|
||||
if len(line.timing.encode("shift_jis_2004")) % 2 != 0:
|
||||
raise SyntaxError(f"Invalid chart line for #bpp=2 : {line}")
|
||||
self.current_chart_lines.append(line)
|
||||
if len(self.current_chart_lines) == 4:
|
||||
self._push_frame()
|
||||
|
||||
def _split_chart_line(self, line: 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 = [
|
||||
self._split_chart_line(memo_line.timing)
|
||||
for memo_line in self.current_chart_lines
|
||||
if memo_line.timing is not None
|
||||
]
|
||||
frame = Memo1Frame(position_part, timing_part)
|
||||
# if the current frame has some timing info
|
||||
if frame.duration > 0:
|
||||
# and the previous frames already cover enough beats
|
||||
if self._frames_duration() >= self.beats_per_section:
|
||||
# then the current frame starts a new section
|
||||
self._push_section()
|
||||
|
||||
self.frames.append(frame)
|
||||
self.current_chart_lines = []
|
||||
|
||||
def _push_section(self):
|
||||
self.sections.append(
|
||||
Memo1LoadedSection(
|
||||
frames=deepcopy(self.frames),
|
||||
length=self.beats_per_section,
|
||||
tempo=self.current_tempo,
|
||||
)
|
||||
)
|
||||
self.frames = []
|
||||
self.section_starting_beat += self.beats_per_section
|
||||
|
||||
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 : {self.current_chart_lines}"
|
||||
)
|
||||
self._push_frame()
|
||||
self._push_section()
|
||||
|
||||
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_double_column_chart_line(line):
|
||||
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}")
|
||||
|
||||
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], Memo1Frame, Decimal, Memo1LoadedSection]]:
|
||||
"""iterate over tuples of
|
||||
currently_defined_symbols, frame, section_starting_beat, section"""
|
||||
local_symbols: Dict[str, Decimal] = {}
|
||||
section_starting_beat = Decimal(0)
|
||||
for section in self.sections:
|
||||
frame_starting_beat = Decimal(0)
|
||||
for i, frame in enumerate(section.frames):
|
||||
if frame.timing_part:
|
||||
frame_starting_beat = sum(f.duration for f in section.frames[:i])
|
||||
local_symbols = {
|
||||
symbol: BeatsTime(symbol_index, len(bar)) + bar_index + decimal_to_beats(frame_starting_beat)
|
||||
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, section_starting_beat, section
|
||||
section_starting_beat += section.length
|
||||
|
||||
def _iter_notes(self) -> Iterator[Union[TapNote, LongNote]]:
|
||||
unfinished_longs: Dict[NotePosition, UnfinishedLongNote] = {}
|
||||
for (
|
||||
currently_defined_symbols,
|
||||
frame,
|
||||
section_starting_beat,
|
||||
section,
|
||||
) 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"{section}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
should_skip.add(pos)
|
||||
note_time = decimal_to_beats(section_starting_beat) + symbol_time
|
||||
yield unfinished_long.ends_at(note_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]
|
||||
note_time = decimal_to_beats(section_starting_beat) + symbol_time
|
||||
unfinished_longs[note_pos] = UnfinishedLongNote(time=note_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
|
||||
note_time = decimal_to_beats(section_starting_beat) + symbol_time
|
||||
yield TapNote(note_time, position)
|
||||
|
||||
def _iter_notes_without_longs(self) -> Iterator[TapNote]:
|
||||
for (
|
||||
currently_defined_symbols,
|
||||
frame,
|
||||
section_starting_beat,
|
||||
_,
|
||||
) 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
|
||||
note_time = decimal_to_beats(section_starting_beat) + symbol_time
|
||||
position = NotePosition(x, y)
|
||||
yield TapNote(note_time, position)
|
||||
|
||||
|
||||
def _load_memo1_file(lines: List[str]) -> Song:
|
||||
parser = Memo1Parser()
|
||||
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_memo1(path: Path) -> Song:
|
||||
files = load_files(path)
|
||||
charts = [_load_memo_file(lines) for _, lines in files.items()]
|
||||
return reduce(Song.merge, charts)
|
@ -233,18 +233,17 @@ class MonoColumnParser(JubeatAnalyserParser):
|
||||
bloc, section.symbols.keys(), should_skip
|
||||
)
|
||||
if arrow_to_note_candidates:
|
||||
unfinished_longs.update(
|
||||
{
|
||||
note.position: note
|
||||
for note in pick_correct_long_note_candidates(
|
||||
solution = pick_correct_long_note_candidates(
|
||||
arrow_to_note_candidates,
|
||||
bloc,
|
||||
should_skip,
|
||||
section.symbols,
|
||||
section_starting_beat,
|
||||
)
|
||||
}
|
||||
)
|
||||
for arrow_pos, note_pos in solution.items():
|
||||
should_skip.add(arrow_pos)
|
||||
should_skip.add(note_pos)
|
||||
symbol = bloc[note_pos.y][note_pos.x]
|
||||
symbol_time = section.symbols[symbol]
|
||||
note_time = decimal_to_beats(section_starting_beat + symbol_time)
|
||||
unfinished_longs[note_pos] = UnfinishedLongNote(time=note_time, position=note_pos, tail_tip=arrow_pos)
|
||||
|
||||
# 3/3 : find regular notes
|
||||
for y, x in product(range(4), range(4)):
|
||||
|
39
jubeatools/formats/jubeat_analyser/tests/test_memo1.py
Normal file
39
jubeatools/formats/jubeat_analyser/tests/test_memo1.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 ..memo1.dump import _dump_memo1_chart
|
||||
from ..memo1.load import Memo1Parser
|
||||
|
||||
|
||||
@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_memo1_chart("", chart, metadata, timing)
|
||||
chart = string_io.getvalue()
|
||||
parser = Memo1Parser()
|
||||
for line in chart.split("\n"):
|
||||
parser.load_line(line)
|
||||
parser.finish_last_few_notes()
|
||||
actual = set(parser.notes())
|
||||
assert notes == actual
|
Loading…
Reference in New Issue
Block a user