1
0
mirror of synced 2024-11-13 18:10:48 +01:00

#memo1 SUPPORT LET'S FUCKING GOOOOOOO

This commit is contained in:
Stepland 2020-07-18 19:40:25 +02:00
parent e16329e05f
commit 96521a8011
7 changed files with 766 additions and 126 deletions

View File

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

View File

@ -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(
arrow_to_note_candidates,
frame.position_part,
should_skip,
currently_defined_symbols,
section_starting_beat,
)
}
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)):

View 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

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

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

View File

@ -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(
arrow_to_note_candidates,
bloc,
should_skip,
section.symbols,
section_starting_beat,
)
}
solution = pick_correct_long_note_candidates(
arrow_to_note_candidates,
bloc,
)
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)):

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 ..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