Fix a lot of jubeat analyser bugs
This commit is contained in:
parent
22f8b3c33f
commit
9e0c4f5c2b
10
CHANGELOG.md
10
CHANGELOG.md
@ -1,6 +1,14 @@
|
||||
# Unreleased
|
||||
## Fixed
|
||||
- [jubeat-analyser] Raise exception earlier when a mono-column file is detected by the other #memo parsers (based on "--" separator lines)
|
||||
- [jubeat-analyser]
|
||||
- Raise exception earlier when a mono-column file is detected by the other #memo parsers (based on "--" separator lines)
|
||||
- [#memo] [#memo1]
|
||||
- Fix incorrect handling of mid-chart `t=` and `b=` commands
|
||||
- Prettify rendering by adding more blank lines between sections
|
||||
- [#memo1] Fix dumping of chart with bpm changes happening on beat times that aren't multiples of 1/4
|
||||
- [#memo2]
|
||||
- Fix parsing of BPM changes
|
||||
- Fix dumping of BPM changes
|
||||
|
||||
# v0.1.3
|
||||
## Changed
|
||||
|
@ -42,7 +42,7 @@ command_grammar = Grammar(
|
||||
equals_value = ws "=" ws value
|
||||
value = value_in_quotes / number
|
||||
value_in_quotes = '"' quoted_value '"'
|
||||
quoted_value = ~r"[^\"]*"
|
||||
quoted_value = ~r"([^\"\\]|\\\"|\\\\)*"
|
||||
number = ~r"-?\d+(\.\d+)?"
|
||||
ws = ~r"[\t ]*"
|
||||
comment = ~r"//.*"
|
||||
@ -76,7 +76,7 @@ class CommandVisitor(NodeVisitor):
|
||||
self.key = letter.text
|
||||
|
||||
def visit_quoted_value(self, node: Node, visited_children: List[Node]) -> None:
|
||||
self.value = node.text
|
||||
self.value = parse_value(node.text)
|
||||
|
||||
def visit_number(self, node: Node, visited_children: List[Node]) -> None:
|
||||
self.value = node.text
|
||||
@ -115,8 +115,38 @@ def dump_command(key: str, value: Any = None) -> str:
|
||||
if isinstance(value, Number):
|
||||
value_part = f"={value}"
|
||||
elif value is not None:
|
||||
value_part = f'="{value}"'
|
||||
escaped = dump_value(str(value))
|
||||
value_part = f'="{escaped}"'
|
||||
else:
|
||||
value_part = ""
|
||||
|
||||
return key_part + value_part
|
||||
|
||||
|
||||
BACKSLASH = "\\"
|
||||
|
||||
|
||||
def parse_value(escaped: str) -> str:
|
||||
"""Unescapes a backslash-escaped string"""
|
||||
res = []
|
||||
i = 0
|
||||
while i < len(escaped):
|
||||
char = escaped[i]
|
||||
if char == BACKSLASH:
|
||||
if i + 1 == len(escaped):
|
||||
raise ValueError("backslash at end of string")
|
||||
else:
|
||||
i += 1
|
||||
|
||||
res.append(escaped[i])
|
||||
i += 1
|
||||
|
||||
return "".join(res)
|
||||
|
||||
|
||||
ESCAPE_TABLE = str.maketrans({'"': BACKSLASH + '"', BACKSLASH: BACKSLASH + BACKSLASH})
|
||||
|
||||
|
||||
def dump_value(value: str) -> str:
|
||||
"""backslash-escapes \ and " from a string"""
|
||||
return value.translate(ESCAPE_TABLE)
|
||||
|
@ -132,7 +132,7 @@ class SortedDefaultDict(SortedDict, Generic[K, V]):
|
||||
@dataclass
|
||||
class _JubeatAnalyerDumpedSection:
|
||||
current_beat: BeatsTime
|
||||
length: Decimal = Decimal(4)
|
||||
length: BeatsTime = BeatsTime(4)
|
||||
commands: Dict[str, Optional[str]] = field(default_factory=dict)
|
||||
symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict)
|
||||
symbols: Dict[BeatsTime, str] = field(default_factory=dict)
|
||||
@ -188,15 +188,15 @@ def create_sections_from_chart(
|
||||
|
||||
header = sections[BeatsTime(0)].commands
|
||||
header["o"] = int(timing.beat_zero_offset * 1000)
|
||||
header["lev"] = int(chart.level)
|
||||
header["lev"] = Decimal(chart.level)
|
||||
header["dif"] = DIFFICULTIES.get(difficulty, 3)
|
||||
if metadata.audio:
|
||||
if metadata.audio is not None:
|
||||
header["m"] = metadata.audio
|
||||
if metadata.title:
|
||||
if metadata.title is not None:
|
||||
header["title"] = metadata.title
|
||||
if metadata.artist:
|
||||
if metadata.artist is not None:
|
||||
header["artist"] = metadata.artist
|
||||
if metadata.cover:
|
||||
if metadata.cover is not None:
|
||||
header["jacket"] = metadata.cover
|
||||
if metadata.preview is not None:
|
||||
header["prevpos"] = int(metadata.preview.start * 1000)
|
||||
|
@ -35,10 +35,10 @@ from .symbols import (
|
||||
|
||||
DIFFICULTIES = {1: "BSC", 2: "ADV", 3: "EXT"}
|
||||
|
||||
SYMBOL_TO_DECIMAL_TIME = {c: Decimal("0.25") * i for i, c in enumerate(NOTE_SYMBOLS)}
|
||||
SYMBOL_TO_BEATS_TIME = {c: BeatsTime("1/4") * i for i, c in enumerate(NOTE_SYMBOLS)}
|
||||
|
||||
CIRCLE_FREE_TO_DECIMAL_TIME = {
|
||||
c: Decimal("0.25") * i for i, c in enumerate(CIRCLE_FREE_SYMBOLS)
|
||||
CIRCLE_FREE_TO_BEATS_TIME = {
|
||||
c: BeatsTime("1/4") * i for i, c in enumerate(CIRCLE_FREE_SYMBOLS)
|
||||
}
|
||||
|
||||
CIRCLE_FREE_TO_NOTE_SYMBOL = dict(zip(CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS))
|
||||
@ -301,14 +301,14 @@ def is_simple_solution(solution: Solution, domains: Candidates) -> bool:
|
||||
class JubeatAnalyserParser:
|
||||
def __init__(self) -> None:
|
||||
self.music: Optional[str] = None
|
||||
self.symbols = deepcopy(SYMBOL_TO_DECIMAL_TIME)
|
||||
self.section_starting_beat = Decimal("0")
|
||||
self.symbols = deepcopy(SYMBOL_TO_BEATS_TIME)
|
||||
self.section_starting_beat = BeatsTime(0)
|
||||
self.current_tempo = Decimal(120)
|
||||
self.timing_events: List[BPMEvent] = []
|
||||
self.offset = 0
|
||||
self.beats_per_section = Decimal(4)
|
||||
self.beats_per_section = BeatsTime(4)
|
||||
self.bytes_per_panel = 2
|
||||
self.level = 1
|
||||
self.level = Decimal(1)
|
||||
self.difficulty: Optional[str] = None
|
||||
self.title: Optional[str] = None
|
||||
self.artist: Optional[str] = None
|
||||
@ -329,7 +329,7 @@ class JubeatAnalyserParser:
|
||||
method()
|
||||
|
||||
def do_b(self, value: str) -> None:
|
||||
self.beats_per_section = Decimal(value)
|
||||
self.beats_per_section = decimal_to_beats(Decimal(value))
|
||||
|
||||
def do_m(self, value: str) -> None:
|
||||
self.music = value
|
||||
@ -344,7 +344,7 @@ class JubeatAnalyserParser:
|
||||
self.current_tempo = Decimal(value)
|
||||
self.timing_events.append(
|
||||
BPMEvent(
|
||||
BeatsTime(self.section_starting_beat),
|
||||
time=self._current_beat(),
|
||||
BPM=self.current_tempo,
|
||||
)
|
||||
)
|
||||
@ -356,7 +356,7 @@ class JubeatAnalyserParser:
|
||||
do_ph = do_pw
|
||||
|
||||
def do_lev(self, value: str) -> None:
|
||||
self.level = int(value)
|
||||
self.level = Decimal(value)
|
||||
|
||||
def do_dif(self, value: str) -> None:
|
||||
dif = int(value)
|
||||
@ -430,7 +430,7 @@ class JubeatAnalyserParser:
|
||||
f"{self.beats_per_section} beats, a symbol cannot happen "
|
||||
f"afterwards at {timing}"
|
||||
)
|
||||
self.symbols[symbol] = timing
|
||||
self.symbols[symbol] = decimal_to_beats(timing)
|
||||
|
||||
def is_short_line(self, line: str) -> bool:
|
||||
return len(line.encode("shift-jis-2004")) < self.bytes_per_panel * 4
|
||||
@ -449,6 +449,9 @@ class JubeatAnalyserParser:
|
||||
f"in mono-column format (1列形式) there should be no {format_} line"
|
||||
)
|
||||
|
||||
def _current_beat(self) -> BeatsTime:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoubleColumnFrame:
|
||||
|
@ -54,7 +54,7 @@ class Frame:
|
||||
positions: Dict[NotePosition, str] = field(default_factory=dict)
|
||||
bars: Dict[int, Dict[int, str]] = field(default_factory=dict)
|
||||
|
||||
def dump(self, length: Decimal) -> Iterator[str]:
|
||||
def dump(self, length: BeatsTime) -> Iterator[str]:
|
||||
self.raise_if_unfit()
|
||||
for pos, bar in zip_longest(self.dump_positions(), self.dump_bars(length)):
|
||||
if bar is None:
|
||||
@ -84,7 +84,7 @@ class Frame:
|
||||
for x in range(4)
|
||||
)
|
||||
|
||||
def dump_bars(self, length: Decimal) -> Iterator[str]:
|
||||
def dump_bars(self, length: BeatsTime) -> Iterator[str]:
|
||||
all_bars = []
|
||||
for i in range(ceil(length * 4)):
|
||||
bar_index = i // 4
|
||||
@ -219,6 +219,10 @@ def _raise_if_unfit_for_memo(chart: Chart, timing: Timing, circle_free: bool) ->
|
||||
)
|
||||
|
||||
|
||||
def _section_factory(b: BeatsTime) -> MemoDumpedSection:
|
||||
return MemoDumpedSection(current_beat=b)
|
||||
|
||||
|
||||
def _dump_memo_chart(
|
||||
difficulty: str,
|
||||
chart: Chart,
|
||||
@ -257,8 +261,9 @@ def _dump_memo_chart(
|
||||
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\n")
|
||||
file.write(
|
||||
"\n\n".join(section.render(circle_free) for _, section in sections.items())
|
||||
)
|
||||
|
||||
return file
|
||||
|
||||
|
@ -13,6 +13,7 @@ from more_itertools import collapse, mark_ends
|
||||
from parsimonious import Grammar, NodeVisitor, ParseError
|
||||
|
||||
from jubeatools.song import (
|
||||
BeatsTime,
|
||||
Chart,
|
||||
LongNote,
|
||||
Metadata,
|
||||
@ -28,7 +29,6 @@ from jubeatools.utils import none_or
|
||||
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,
|
||||
@ -53,18 +53,18 @@ from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
|
||||
|
||||
class MemoFrame(DoubleColumnFrame):
|
||||
@property
|
||||
def duration(self) -> Decimal:
|
||||
res = 0
|
||||
for t in self.timing_part:
|
||||
res += len(t)
|
||||
return Decimal("0.25") * res
|
||||
def duration(self) -> BeatsTime:
|
||||
# This is wrong for the last frame in a section if the section has a
|
||||
# decimal beat length that's not a multiple of 1/4
|
||||
number_of_symbols = sum(len(t) for t in self.timing_part)
|
||||
return BeatsTime("1/4") * number_of_symbols
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoLoadedSection:
|
||||
frames: List[MemoFrame]
|
||||
symbols: Dict[str, Decimal]
|
||||
length: Decimal
|
||||
symbols: Dict[str, BeatsTime]
|
||||
length: BeatsTime
|
||||
tempo: Decimal
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -87,15 +87,26 @@ class MemoParser(JubeatAnalyserParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.current_chart_lines: List[DoubleColumnChartLine] = []
|
||||
self.symbols: Dict[str, Decimal] = {}
|
||||
self.frames: List[MemoFrame] = []
|
||||
self.current_frames: List[MemoFrame] = []
|
||||
self.sections: List[MemoLoadedSection] = []
|
||||
|
||||
def do_memo(self) -> None:
|
||||
...
|
||||
|
||||
def do_b(self, value: str) -> None:
|
||||
"""Because of the way the parser works,
|
||||
b= commands must mark the end of a section to be properly taken into
|
||||
account"""
|
||||
if self.current_chart_lines:
|
||||
raise SyntaxError("Found a b= command before the end of a frame")
|
||||
if self.current_frames and self._frames_duration() < self.beats_per_section:
|
||||
raise SyntaxError("Found a b= command before the end of a section")
|
||||
|
||||
self._push_section()
|
||||
super().do_b(value)
|
||||
|
||||
def do_bpp(self, value: str) -> None:
|
||||
if self.sections or self.frames:
|
||||
if self.sections or self.current_frames:
|
||||
raise ValueError("jubeatools does not handle changes of #bpp halfway")
|
||||
else:
|
||||
self._do_bpp(value)
|
||||
@ -106,10 +117,23 @@ class MemoParser(JubeatAnalyserParser):
|
||||
if len(self.current_chart_lines) == 4:
|
||||
self._push_frame()
|
||||
|
||||
def _frames_duration(self) -> Decimal:
|
||||
return sum((frame.duration for frame in self.frames), start=Decimal(0))
|
||||
def _frames_duration(self) -> BeatsTime:
|
||||
return sum(
|
||||
(frame.duration for frame in self.current_frames), start=BeatsTime(0)
|
||||
)
|
||||
|
||||
def _current_beat(self) -> BeatsTime:
|
||||
# If we've already seen enough beats, we need to circumvent the wrong
|
||||
# duration computation
|
||||
if self._frames_duration() >= self.beats_per_section:
|
||||
frames_duration = self.beats_per_section
|
||||
else:
|
||||
frames_duration = self._frames_duration()
|
||||
|
||||
return self.section_starting_beat + frames_duration
|
||||
|
||||
def _push_frame(self) -> None:
|
||||
"""Take all chart lines and push them to a new frame"""
|
||||
position_part = [
|
||||
self._split_chart_line(memo_line.position)
|
||||
for memo_line in self.current_chart_lines
|
||||
@ -127,19 +151,25 @@ class MemoParser(JubeatAnalyserParser):
|
||||
# then the current frame starts a new section
|
||||
self._push_section()
|
||||
|
||||
self.frames.append(frame)
|
||||
self.current_frames.append(frame)
|
||||
self.current_chart_lines = []
|
||||
|
||||
def _push_section(self) -> None:
|
||||
"""Take all currently stacked frames and push them to a new section,
|
||||
Move time forward by the number of beats per section"""
|
||||
if not self.current_frames:
|
||||
raise RuntimeError(
|
||||
"Tried pushing a new section but no frames are currently stacked"
|
||||
)
|
||||
self.sections.append(
|
||||
MemoLoadedSection(
|
||||
frames=self.frames,
|
||||
frames=self.current_frames,
|
||||
symbols=deepcopy(self.symbols),
|
||||
length=self.beats_per_section,
|
||||
tempo=self.current_tempo,
|
||||
)
|
||||
)
|
||||
self.frames = []
|
||||
self.current_frames = []
|
||||
self.section_starting_beat += self.beats_per_section
|
||||
|
||||
def finish_last_few_notes(self) -> None:
|
||||
@ -179,20 +209,22 @@ class MemoParser(JubeatAnalyserParser):
|
||||
|
||||
def _iter_frames(
|
||||
self,
|
||||
) -> Iterator[Tuple[Mapping[str, Decimal], MemoFrame, Decimal, MemoLoadedSection]]:
|
||||
) -> Iterator[
|
||||
Tuple[Mapping[str, BeatsTime], MemoFrame, BeatsTime, MemoLoadedSection]
|
||||
]:
|
||||
"""iterate over tuples of
|
||||
currently_defined_symbols, frame_starting_beat, frame, section_starting_beat, section"""
|
||||
local_symbols: Dict[str, Decimal] = {}
|
||||
section_starting_beat = Decimal(0)
|
||||
local_symbols: Dict[str, BeatsTime] = {}
|
||||
section_starting_beat = BeatsTime(0)
|
||||
for section in self.sections:
|
||||
frame_starting_beat = Decimal(0)
|
||||
frame_starting_beat = BeatsTime(0)
|
||||
for i, frame in enumerate(section.frames):
|
||||
if frame.timing_part:
|
||||
frame_starting_beat = sum(
|
||||
(f.duration for f in section.frames[:i]), start=Decimal(0)
|
||||
(f.duration for f in section.frames[:i]), start=BeatsTime(0)
|
||||
)
|
||||
local_symbols = {
|
||||
symbol: Decimal("0.25") * i + frame_starting_beat
|
||||
symbol: BeatsTime("1/4") * i + frame_starting_beat
|
||||
for i, symbol in enumerate(collapse(frame.timing_part))
|
||||
if symbol not in EMPTY_BEAT_SYMBOLS
|
||||
}
|
||||
@ -232,7 +264,7 @@ class MemoParser(JubeatAnalyserParser):
|
||||
continue
|
||||
|
||||
should_skip.add(pos)
|
||||
note_time = decimal_to_beats(section_starting_beat + symbol_time)
|
||||
note_time = section_starting_beat + symbol_time
|
||||
yield unfinished_long.ends_at(note_time)
|
||||
|
||||
unfinished_longs = {
|
||||
@ -253,7 +285,7 @@ class MemoParser(JubeatAnalyserParser):
|
||||
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)
|
||||
note_time = section_starting_beat + symbol_time
|
||||
unfinished_longs[note_pos] = UnfinishedLongNote(
|
||||
time=note_time, position=note_pos, tail_tip=arrow_pos
|
||||
)
|
||||
@ -268,7 +300,7 @@ class MemoParser(JubeatAnalyserParser):
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
except KeyError:
|
||||
continue
|
||||
note_time = decimal_to_beats(section_starting_beat + symbol_time)
|
||||
note_time = section_starting_beat + symbol_time
|
||||
yield TapNote(note_time, position)
|
||||
|
||||
def _iter_notes_without_longs(self) -> Iterator[TapNote]:
|
||||
@ -285,7 +317,7 @@ class MemoParser(JubeatAnalyserParser):
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
except KeyError:
|
||||
continue
|
||||
note_time = decimal_to_beats(section_starting_beat + symbol_time)
|
||||
note_time = section_starting_beat + symbol_time
|
||||
position = NotePosition(x, y)
|
||||
yield TapNote(note_time, position)
|
||||
|
||||
@ -296,7 +328,7 @@ def _load_memo_file(lines: List[str]) -> Song:
|
||||
try:
|
||||
parser.load_line(raw_line)
|
||||
except Exception as e:
|
||||
raise SyntaxError(f"On line {i}\n{e}") from None
|
||||
raise SyntaxError(f"On line {i+1}\n{e}")
|
||||
|
||||
parser.finish_last_few_notes()
|
||||
metadata = Metadata(
|
||||
|
@ -55,7 +55,7 @@ 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]:
|
||||
def dump(self, length: BeatsTime) -> Iterator[str]:
|
||||
# Check that bars are contiguous
|
||||
for a, b in windowed(sorted(self.bars), 2):
|
||||
if a is not None and b is not None:
|
||||
@ -77,7 +77,7 @@ class Frame:
|
||||
for x in range(4)
|
||||
)
|
||||
|
||||
def dump_bars(self, length: Decimal) -> Iterator[str]:
|
||||
def dump_bars(self, length: BeatsTime) -> Iterator[str]:
|
||||
for i in range(ceil(length)):
|
||||
if i in self.bars:
|
||||
yield f"|{''.join(self.bars[i])}|"
|
||||
@ -110,7 +110,9 @@ class Memo1DumpedSection(JubeatAnalyserDumpedSection):
|
||||
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))
|
||||
bar_length = lcm(
|
||||
*((note.time - self.current_beat).denominator for note in notes)
|
||||
)
|
||||
if bar_length < 3:
|
||||
bar_length = 4
|
||||
bar_dict: Dict[int, str] = {}
|
||||
@ -215,6 +217,10 @@ def _raise_if_unfit_for_memo1(
|
||||
)
|
||||
|
||||
|
||||
def _section_factory(b: BeatsTime) -> Memo1DumpedSection:
|
||||
return Memo1DumpedSection(current_beat=b)
|
||||
|
||||
|
||||
def _dump_memo1_chart(
|
||||
difficulty: str,
|
||||
chart: Chart,
|
||||
@ -236,8 +242,9 @@ def _dump_memo1_chart(
|
||||
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")
|
||||
file.write(
|
||||
"\n\n".join(section.render(circle_free) for _, section in sections.items())
|
||||
)
|
||||
|
||||
return file
|
||||
|
||||
|
@ -29,7 +29,6 @@ from jubeatools.utils import none_or
|
||||
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,
|
||||
@ -54,14 +53,16 @@ from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
|
||||
|
||||
class Memo1Frame(DoubleColumnFrame):
|
||||
@property
|
||||
def duration(self) -> Decimal:
|
||||
return Decimal(len(self.timing_part))
|
||||
def duration(self) -> BeatsTime:
|
||||
# This is wrong for the last frame in a section if the section has a
|
||||
# length in beats that's not an integer
|
||||
return BeatsTime(len(self.timing_part))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Memo1LoadedSection:
|
||||
frames: List[Memo1Frame]
|
||||
length: Decimal
|
||||
length: BeatsTime
|
||||
tempo: Decimal
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -82,14 +83,26 @@ class Memo1Parser(JubeatAnalyserParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.current_chart_lines: List[DoubleColumnChartLine] = []
|
||||
self.frames: List[Memo1Frame] = []
|
||||
self.current_frames: List[Memo1Frame] = []
|
||||
self.sections: List[Memo1LoadedSection] = []
|
||||
|
||||
def do_memo1(self) -> None:
|
||||
...
|
||||
|
||||
def do_b(self, value: str) -> None:
|
||||
"""Because of the way the parser works,
|
||||
b= commands must mark the end of a section to be properly taken into
|
||||
account"""
|
||||
if self.current_chart_lines:
|
||||
raise SyntaxError("Found a b= command before the end of a frame")
|
||||
if self.current_frames and self._frames_duration() < self.beats_per_section:
|
||||
raise SyntaxError("Found a b= command before the end of a section")
|
||||
|
||||
self._push_section()
|
||||
super().do_b(value)
|
||||
|
||||
def do_bpp(self, value: str) -> None:
|
||||
if self.sections or self.frames:
|
||||
if self.sections or self.current_frames:
|
||||
raise ValueError("jubeatools does not handle changes of #bpp halfway")
|
||||
else:
|
||||
self._do_bpp(value)
|
||||
@ -100,8 +113,20 @@ class Memo1Parser(JubeatAnalyserParser):
|
||||
if len(self.current_chart_lines) == 4:
|
||||
self._push_frame()
|
||||
|
||||
def _frames_duration(self) -> Decimal:
|
||||
return sum((frame.duration for frame in self.frames), start=Decimal(0))
|
||||
def _frames_duration(self) -> BeatsTime:
|
||||
return sum(
|
||||
(frame.duration for frame in self.current_frames), start=BeatsTime(0)
|
||||
)
|
||||
|
||||
def _current_beat(self) -> BeatsTime:
|
||||
# If we've already seen enough beats, we need to circumvent the wrong
|
||||
# duration computation
|
||||
if self._frames_duration() >= self.beats_per_section:
|
||||
frames_duration = self.beats_per_section
|
||||
else:
|
||||
frames_duration = self._frames_duration()
|
||||
|
||||
return self.section_starting_beat + frames_duration
|
||||
|
||||
def _push_frame(self) -> None:
|
||||
position_part = [
|
||||
@ -121,18 +146,24 @@ class Memo1Parser(JubeatAnalyserParser):
|
||||
# then the current frame starts a new section
|
||||
self._push_section()
|
||||
|
||||
self.frames.append(frame)
|
||||
self.current_frames.append(frame)
|
||||
self.current_chart_lines = []
|
||||
|
||||
def _push_section(self) -> None:
|
||||
"""Take all currently stacked frames and push them to a new section,
|
||||
Move time forward by the number of beats per section"""
|
||||
if not self.current_frames:
|
||||
raise RuntimeError(
|
||||
"Tried pushing a new section but no frames are currently stacked"
|
||||
)
|
||||
self.sections.append(
|
||||
Memo1LoadedSection(
|
||||
frames=deepcopy(self.frames),
|
||||
frames=deepcopy(self.current_frames),
|
||||
length=self.beats_per_section,
|
||||
tempo=self.current_tempo,
|
||||
)
|
||||
)
|
||||
self.frames = []
|
||||
self.current_frames = []
|
||||
self.section_starting_beat += self.beats_per_section
|
||||
|
||||
def finish_last_few_notes(self) -> None:
|
||||
@ -170,23 +201,23 @@ class Memo1Parser(JubeatAnalyserParser):
|
||||
def _iter_frames(
|
||||
self,
|
||||
) -> Iterator[
|
||||
Tuple[Mapping[str, BeatsTime], Memo1Frame, Decimal, Memo1LoadedSection]
|
||||
Tuple[Mapping[str, BeatsTime], Memo1Frame, BeatsTime, Memo1LoadedSection]
|
||||
]:
|
||||
"""iterate over tuples of
|
||||
currently_defined_symbols, frame, section_starting_beat, section"""
|
||||
local_symbols = {}
|
||||
section_starting_beat = Decimal(0)
|
||||
section_starting_beat = BeatsTime(0)
|
||||
for section in self.sections:
|
||||
frame_starting_beat = Decimal(0)
|
||||
frame_starting_beat = BeatsTime(0)
|
||||
for i, frame in enumerate(section.frames):
|
||||
if frame.timing_part:
|
||||
frame_starting_beat = sum(
|
||||
(f.duration for f in section.frames[:i]), start=Decimal(0)
|
||||
(f.duration for f in section.frames[:i]), start=BeatsTime(0)
|
||||
)
|
||||
local_symbols = {
|
||||
symbol: BeatsTime(symbol_index, len(bar))
|
||||
+ bar_index
|
||||
+ decimal_to_beats(frame_starting_beat)
|
||||
+ 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
|
||||
@ -226,7 +257,7 @@ class Memo1Parser(JubeatAnalyserParser):
|
||||
continue
|
||||
|
||||
should_skip.add(pos)
|
||||
note_time = decimal_to_beats(section_starting_beat) + symbol_time
|
||||
note_time = section_starting_beat + symbol_time
|
||||
yield unfinished_long.ends_at(note_time)
|
||||
|
||||
unfinished_longs = {
|
||||
@ -247,7 +278,7 @@ class Memo1Parser(JubeatAnalyserParser):
|
||||
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
|
||||
note_time = section_starting_beat + symbol_time
|
||||
unfinished_longs[note_pos] = UnfinishedLongNote(
|
||||
time=note_time, position=note_pos, tail_tip=arrow_pos
|
||||
)
|
||||
@ -262,7 +293,7 @@ class Memo1Parser(JubeatAnalyserParser):
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
except KeyError:
|
||||
continue
|
||||
note_time = decimal_to_beats(section_starting_beat) + symbol_time
|
||||
note_time = section_starting_beat + symbol_time
|
||||
yield TapNote(note_time, position)
|
||||
|
||||
def _iter_notes_without_longs(self) -> Iterator[TapNote]:
|
||||
@ -279,7 +310,7 @@ class Memo1Parser(JubeatAnalyserParser):
|
||||
symbol_time = currently_defined_symbols[symbol]
|
||||
except KeyError:
|
||||
continue
|
||||
note_time = decimal_to_beats(section_starting_beat) + symbol_time
|
||||
note_time = section_starting_beat + symbol_time
|
||||
position = NotePosition(x, y)
|
||||
yield TapNote(note_time, position)
|
||||
|
||||
|
@ -292,8 +292,8 @@ def _dump_memo2_chart(
|
||||
# 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)
|
||||
section_beat = event.time - (event.time % 4)
|
||||
sections[section_beat].events.append(event)
|
||||
|
||||
# Fill sections with notes
|
||||
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
|
||||
@ -307,15 +307,15 @@ def _dump_memo2_chart(
|
||||
file.write(f"// https://github.com/Stepland/jubeatools\n\n")
|
||||
|
||||
# Header
|
||||
file.write(dump_command("lev", int(chart.level)) + "\n")
|
||||
file.write(dump_command("lev", Decimal(chart.level)) + "\n")
|
||||
file.write(dump_command("dif", DIFFICULTIES.get(difficulty, 1)) + "\n")
|
||||
if metadata.audio:
|
||||
if metadata.audio is not None:
|
||||
file.write(dump_command("m", metadata.audio) + "\n")
|
||||
if metadata.title:
|
||||
if metadata.title is not None:
|
||||
file.write(dump_command("title", metadata.title) + "\n")
|
||||
if metadata.artist:
|
||||
if metadata.artist is not None:
|
||||
file.write(dump_command("artist", metadata.artist) + "\n")
|
||||
if metadata.cover:
|
||||
if metadata.cover is not None:
|
||||
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")
|
||||
|
@ -31,7 +31,6 @@ from jubeatools.utils import none_or
|
||||
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,
|
||||
@ -98,6 +97,13 @@ class Memo2ChartLine:
|
||||
position: str
|
||||
timing: Optional[List[str]]
|
||||
|
||||
@property
|
||||
def duration(self) -> BeatsTime:
|
||||
if self.timing:
|
||||
return BeatsTime(1)
|
||||
else:
|
||||
return BeatsTime(0)
|
||||
|
||||
|
||||
memo2_chart_line_grammar = Grammar(
|
||||
r"""
|
||||
@ -185,26 +191,27 @@ class Memo2Parser(JubeatAnalyserParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.current_chart_lines: List[Memo2ChartLine] = []
|
||||
self.current_beat = BeatsTime(0)
|
||||
self.frames: List[Memo2Frame] = []
|
||||
|
||||
def do_b(self, value: str) -> None:
|
||||
raise ValueError(
|
||||
raise RuntimeError(
|
||||
"beat command (b=...) found, this commands cannot be used in #memo2 files"
|
||||
)
|
||||
|
||||
def do_t(self, value: str) -> None:
|
||||
if self.frames:
|
||||
raise ValueError(
|
||||
raise RuntimeError(
|
||||
"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)))
|
||||
self.timing_events.append(
|
||||
BPMEvent(self._current_beat(), BPM=Decimal(value))
|
||||
)
|
||||
|
||||
def do_r(self, value: str) -> None:
|
||||
if self.frames:
|
||||
raise ValueError(
|
||||
raise RuntimeError(
|
||||
"offset increase command (r=...) found outside of the file "
|
||||
"header, this is not supported by jubeatools"
|
||||
)
|
||||
@ -219,7 +226,7 @@ class Memo2Parser(JubeatAnalyserParser):
|
||||
|
||||
def do_bpp(self, value: Union[int, str]) -> None:
|
||||
if self.frames:
|
||||
raise ValueError("jubeatools does not handle changes of #bpp halfway")
|
||||
raise RuntimeError("jubeatools does not handle changes of #bpp halfway")
|
||||
else:
|
||||
self._do_bpp(value)
|
||||
|
||||
@ -256,10 +263,12 @@ class Memo2Parser(JubeatAnalyserParser):
|
||||
in_bar_beat += symbol_duration
|
||||
elif isinstance(event, BPM):
|
||||
self.timing_events.append(
|
||||
BPMEvent(time=self.current_beat + in_bar_beat, BPM=event.value)
|
||||
BPMEvent(
|
||||
time=self._current_beat() + in_bar_beat, BPM=event.value
|
||||
)
|
||||
)
|
||||
elif isinstance(event, Stop):
|
||||
time = self.current_beat + in_bar_beat
|
||||
time = self._current_beat() + in_bar_beat
|
||||
if time != 0:
|
||||
raise ValueError(
|
||||
"Chart contains a pause that's not happening at the "
|
||||
@ -274,9 +283,17 @@ class Memo2Parser(JubeatAnalyserParser):
|
||||
if len(self.current_chart_lines) == 4:
|
||||
self._push_frame()
|
||||
|
||||
def _current_beat(self) -> BeatsTime:
|
||||
return self._frames_duration() + self._lines_duration()
|
||||
|
||||
def _frames_duration(self) -> BeatsTime:
|
||||
return sum((frame.duration for frame in self.frames), start=BeatsTime(0))
|
||||
|
||||
def _lines_duration(self) -> BeatsTime:
|
||||
return sum(
|
||||
(line.duration for line in self.current_chart_lines), start=BeatsTime(0)
|
||||
)
|
||||
|
||||
def _push_frame(self) -> None:
|
||||
position_part = [
|
||||
self._split_chart_line(memo_line.position)
|
||||
|
@ -139,6 +139,10 @@ def _raise_if_unfit_for_mono_column(
|
||||
)
|
||||
|
||||
|
||||
def _section_factory(b: BeatsTime) -> MonoColumnDumpedSection:
|
||||
return MonoColumnDumpedSection(current_beat=b)
|
||||
|
||||
|
||||
def _dump_mono_column_chart(
|
||||
difficulty: str,
|
||||
chart: Chart,
|
||||
@ -150,7 +154,7 @@ def _dump_mono_column_chart(
|
||||
_raise_if_unfit_for_mono_column(chart, timing, circle_free)
|
||||
|
||||
sections = create_sections_from_chart(
|
||||
MonoColumnDumpedSection, chart, difficulty, timing, metadata, circle_free
|
||||
_section_factory, chart, difficulty, timing, metadata, circle_free
|
||||
)
|
||||
|
||||
# Define extra symbols
|
||||
|
@ -32,7 +32,7 @@ from jubeatools.utils import none_or
|
||||
from ..command import is_command, parse_command
|
||||
from ..files import load_files
|
||||
from ..load_tools import (
|
||||
CIRCLE_FREE_TO_DECIMAL_TIME,
|
||||
CIRCLE_FREE_TO_BEATS_TIME,
|
||||
LONG_ARROWS,
|
||||
LONG_DIRECTION,
|
||||
JubeatAnalyserParser,
|
||||
@ -81,11 +81,6 @@ def parse_mono_column_chart_line(line: str) -> str:
|
||||
return MonoColumnChartLineVisitor().visit(mono_column_chart_line_grammar.parse(line)) # type: ignore
|
||||
|
||||
|
||||
SYMBOL_TO_DECIMAL_TIME = {
|
||||
symbol: Decimal("0.25") * index for index, symbol in enumerate(NOTE_SYMBOLS)
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonoColumnLoadedSection:
|
||||
"""
|
||||
@ -98,8 +93,8 @@ class MonoColumnLoadedSection:
|
||||
"""
|
||||
|
||||
chart_lines: List[str]
|
||||
symbols: Dict[str, Decimal]
|
||||
length: Decimal
|
||||
symbols: Dict[str, BeatsTime]
|
||||
length: BeatsTime
|
||||
tempo: Decimal
|
||||
|
||||
def blocs(self, bpp: int = 2) -> Iterator[List[List[str]]]:
|
||||
@ -120,6 +115,9 @@ class MonoColumnParser(JubeatAnalyserParser):
|
||||
self.current_chart_lines: List[str] = []
|
||||
self.sections: List[MonoColumnLoadedSection] = []
|
||||
|
||||
def _current_beat(self) -> BeatsTime:
|
||||
return self.section_starting_beat
|
||||
|
||||
def do_bpp(self, value: Union[int, str]) -> None:
|
||||
if self.sections:
|
||||
raise ValueError(
|
||||
@ -179,8 +177,8 @@ class MonoColumnParser(JubeatAnalyserParser):
|
||||
|
||||
def _iter_blocs(
|
||||
self,
|
||||
) -> Iterator[Tuple[Decimal, MonoColumnLoadedSection, List[List[str]]]]:
|
||||
section_starting_beat = Decimal(0)
|
||||
) -> Iterator[Tuple[BeatsTime, MonoColumnLoadedSection, List[List[str]]]]:
|
||||
section_starting_beat = BeatsTime(0)
|
||||
for section in self.sections:
|
||||
for bloc in section.blocs():
|
||||
yield section_starting_beat, section, bloc
|
||||
@ -198,10 +196,8 @@ class MonoColumnParser(JubeatAnalyserParser):
|
||||
if self.circle_free:
|
||||
if symbol in CIRCLE_FREE_SYMBOLS:
|
||||
should_skip.add(pos)
|
||||
symbol_time = CIRCLE_FREE_TO_DECIMAL_TIME[symbol]
|
||||
note_time = decimal_to_beats(
|
||||
section_starting_beat + symbol_time
|
||||
)
|
||||
symbol_time = CIRCLE_FREE_TO_BEATS_TIME[symbol]
|
||||
note_time = section_starting_beat + symbol_time
|
||||
yield unfinished_long.ends_at(note_time)
|
||||
elif symbol in section.symbols:
|
||||
raise SyntaxError(
|
||||
@ -212,9 +208,7 @@ class MonoColumnParser(JubeatAnalyserParser):
|
||||
if symbol in section.symbols:
|
||||
should_skip.add(pos)
|
||||
symbol_time = section.symbols[symbol]
|
||||
note_time = decimal_to_beats(
|
||||
section_starting_beat + symbol_time
|
||||
)
|
||||
note_time = section_starting_beat + symbol_time
|
||||
yield unfinished_long.ends_at(note_time)
|
||||
|
||||
unfinished_longs = {
|
||||
@ -235,7 +229,7 @@ class MonoColumnParser(JubeatAnalyserParser):
|
||||
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)
|
||||
note_time = section_starting_beat + symbol_time
|
||||
unfinished_longs[note_pos] = UnfinishedLongNote(
|
||||
time=note_time, position=note_pos, tail_tip=arrow_pos
|
||||
)
|
||||
@ -248,17 +242,17 @@ class MonoColumnParser(JubeatAnalyserParser):
|
||||
symbol = bloc[y][x]
|
||||
if symbol in section.symbols:
|
||||
symbol_time = section.symbols[symbol]
|
||||
note_time = decimal_to_beats(section_starting_beat + symbol_time)
|
||||
note_time = section_starting_beat + symbol_time
|
||||
yield TapNote(note_time, position)
|
||||
|
||||
def _iter_notes_without_longs(self) -> Iterator[TapNote]:
|
||||
section_starting_beat = Decimal(0)
|
||||
section_starting_beat = BeatsTime(0)
|
||||
for section in self.sections:
|
||||
for bloc, y, x in product(section.blocs(), range(4), range(4)):
|
||||
symbol = bloc[y][x]
|
||||
if symbol in section.symbols:
|
||||
symbol_time = section.symbols[symbol]
|
||||
note_time = decimal_to_beats(section_starting_beat + symbol_time)
|
||||
note_time = section_starting_beat + symbol_time
|
||||
position = NotePosition(x, y)
|
||||
yield TapNote(note_time, position)
|
||||
section_starting_beat += section.length
|
||||
|
29
jubeatools/formats/jubeat_analyser/tests/memo/example2.py
Normal file
29
jubeatools/formats/jubeat_analyser/tests/memo/example2.py
Normal file
@ -0,0 +1,29 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from jubeatools.song import *
|
||||
|
||||
data = (
|
||||
Song(
|
||||
metadata=Metadata(
|
||||
title="",
|
||||
artist="",
|
||||
audio=Path('000"'),
|
||||
cover=Path(""),
|
||||
preview=None,
|
||||
preview_file=None,
|
||||
),
|
||||
charts={
|
||||
"BSC": Chart(
|
||||
level=Decimal(0),
|
||||
timing=Timing(
|
||||
events=[BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000"))],
|
||||
beat_zero_offset=Decimal("0.000"),
|
||||
),
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
32
jubeatools/formats/jubeat_analyser/tests/memo/example3.py
Normal file
32
jubeatools/formats/jubeat_analyser/tests/memo/example3.py
Normal file
@ -0,0 +1,32 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from jubeatools.song import *
|
||||
|
||||
data = (
|
||||
Song(
|
||||
metadata=Metadata(
|
||||
title="",
|
||||
artist="",
|
||||
audio=Path(""),
|
||||
cover=Path(""),
|
||||
preview=None,
|
||||
preview_file=None,
|
||||
),
|
||||
charts={
|
||||
"BSC": Chart(
|
||||
level=Decimal("0.0"),
|
||||
timing=Timing(
|
||||
events=[
|
||||
BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")),
|
||||
BPMEvent(time=Fraction(17, 4), BPM=Decimal("1.000")),
|
||||
],
|
||||
beat_zero_offset=Decimal("0.000"),
|
||||
),
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
@ -1,41 +1,38 @@
|
||||
import tempfile
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import Set, Union
|
||||
|
||||
from hypothesis import example, given
|
||||
from hypothesis import note as hypothesis_note
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from jubeatools.formats.jubeat_analyser.memo.dump import _dump_memo_chart
|
||||
from jubeatools.formats.jubeat_analyser.memo.load import MemoParser
|
||||
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 jubeatools import song
|
||||
from jubeatools.formats.enum import Format
|
||||
from jubeatools.formats.guess import guess_format
|
||||
from jubeatools.formats.jubeat_analyser.memo.dump import _dump_memo_chart, dump_memo
|
||||
from jubeatools.formats.jubeat_analyser.memo.load import MemoParser, load_memo
|
||||
from jubeatools.testutils import strategies as jbst
|
||||
from jubeatools.testutils.typing import DrawFunc
|
||||
|
||||
from . import example1
|
||||
from ..test_utils import load_and_dump_then_check, memo_compatible_song
|
||||
from . import example1, example2, example3
|
||||
|
||||
|
||||
@given(notes_strat(NoteOption.LONGS))
|
||||
@given(jbst.notes(jbst.NoteOption.LONGS))
|
||||
@example(example1.notes)
|
||||
def test_many_notes(notes: Set[Union[TapNote, LongNote]]) -> None:
|
||||
timing = Timing(
|
||||
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
|
||||
def test_that_notes_roundtrip(notes: Set[Union[song.TapNote, song.LongNote]]) -> None:
|
||||
timing = song.Timing(
|
||||
events=[song.BPMEvent(song.BeatsTime(0), Decimal(120))],
|
||||
beat_zero_offset=song.SecondsTime(0),
|
||||
)
|
||||
chart = Chart(
|
||||
chart = song.Chart(
|
||||
level=Decimal(0),
|
||||
timing=timing,
|
||||
notes=sorted(notes, key=lambda n: (n.time, n.position)),
|
||||
)
|
||||
metadata = Metadata("", "", Path(""), Path(""))
|
||||
metadata = song.Metadata("", "", Path(""), Path(""))
|
||||
string_io = _dump_memo_chart("", chart, metadata, timing, False)
|
||||
chart_text = string_io.getvalue()
|
||||
parser = MemoParser()
|
||||
@ -44,3 +41,10 @@ def test_many_notes(notes: Set[Union[TapNote, LongNote]]) -> None:
|
||||
parser.finish_last_few_notes()
|
||||
actual = set(parser.notes())
|
||||
assert notes == actual
|
||||
|
||||
|
||||
@given(memo_compatible_song(), st.booleans())
|
||||
@example(*example2.data)
|
||||
@example(*example3.data)
|
||||
def test_that_full_chart_roundtrips(song: song.Song, circle_free: bool) -> None:
|
||||
load_and_dump_then_check(Format.MEMO, song, circle_free)
|
||||
|
36
jubeatools/formats/jubeat_analyser/tests/memo1/example1.py
Normal file
36
jubeatools/formats/jubeat_analyser/tests/memo1/example1.py
Normal file
@ -0,0 +1,36 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from jubeatools.song import *
|
||||
|
||||
data = (
|
||||
Song(
|
||||
metadata=Metadata(
|
||||
title="",
|
||||
artist="",
|
||||
audio=Path(""),
|
||||
cover=Path(""),
|
||||
preview=None,
|
||||
preview_file=None,
|
||||
),
|
||||
charts={
|
||||
"BSC": Chart(
|
||||
level=Decimal("0.0"),
|
||||
timing=Timing(
|
||||
events=[
|
||||
BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")),
|
||||
BPMEvent(time=Fraction(13, 3), BPM=Decimal("1.000")),
|
||||
],
|
||||
beat_zero_offset=Decimal("0.000"),
|
||||
),
|
||||
notes=[
|
||||
TapNote(time=Fraction(0, 1), position=NotePosition(x=0, y=0)),
|
||||
TapNote(time=Fraction(0, 1), position=NotePosition(x=0, y=1)),
|
||||
TapNote(time=Fraction(9, 2), position=NotePosition(x=0, y=0)),
|
||||
],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
44
jubeatools/formats/jubeat_analyser/tests/memo1/test_memo1.py
Normal file
44
jubeatools/formats/jubeat_analyser/tests/memo1/test_memo1.py
Normal file
@ -0,0 +1,44 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import List, Union
|
||||
|
||||
from hypothesis import example, given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from jubeatools import song
|
||||
from jubeatools.formats import Format
|
||||
from jubeatools.formats.jubeat_analyser.memo1.dump import _dump_memo1_chart, dump_memo1
|
||||
from jubeatools.formats.jubeat_analyser.memo1.load import Memo1Parser
|
||||
from jubeatools.testutils.strategies import NoteOption
|
||||
from jubeatools.testutils.strategies import notes as notes_strat
|
||||
|
||||
from ..test_utils import load_and_dump_then_check, memo_compatible_song
|
||||
from . import example1
|
||||
|
||||
|
||||
@given(notes_strat(NoteOption.LONGS))
|
||||
def test_that_notes_roundtrip(notes: List[Union[song.TapNote, song.LongNote]]) -> None:
|
||||
timing = song.Timing(
|
||||
events=[song.BPMEvent(song.BeatsTime(0), Decimal(120))],
|
||||
beat_zero_offset=song.SecondsTime(0),
|
||||
)
|
||||
chart = song.Chart(
|
||||
level=Decimal(0),
|
||||
timing=timing,
|
||||
notes=sorted(notes, key=lambda n: (n.time, n.position)),
|
||||
)
|
||||
metadata = song.Metadata("", "", Path(""), Path(""))
|
||||
string_io = _dump_memo1_chart("", chart, metadata, timing)
|
||||
chart_text = string_io.getvalue()
|
||||
parser = Memo1Parser()
|
||||
for line in chart_text.split("\n"):
|
||||
parser.load_line(line)
|
||||
parser.finish_last_few_notes()
|
||||
actual = set(parser.notes())
|
||||
assert notes == actual
|
||||
|
||||
|
||||
@given(memo_compatible_song(), st.booleans())
|
||||
@example(*example1.data)
|
||||
def test_that_full_chart_roundtrips(song: song.Song, circle_free: bool) -> None:
|
||||
load_and_dump_then_check(Format.MEMO_1, song, circle_free)
|
32
jubeatools/formats/jubeat_analyser/tests/memo2/example1.py
Normal file
32
jubeatools/formats/jubeat_analyser/tests/memo2/example1.py
Normal file
@ -0,0 +1,32 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from jubeatools.song import *
|
||||
|
||||
data = (
|
||||
Song(
|
||||
metadata=Metadata(
|
||||
title="",
|
||||
artist="",
|
||||
audio=Path(""),
|
||||
cover=Path(""),
|
||||
preview=None,
|
||||
preview_file=None,
|
||||
),
|
||||
charts={
|
||||
"BSC": Chart(
|
||||
level=Decimal("0.0"),
|
||||
timing=Timing(
|
||||
events=[
|
||||
BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")),
|
||||
BPMEvent(time=Fraction(4, 1), BPM=Decimal("1.000")),
|
||||
],
|
||||
beat_zero_offset=Decimal("0.000"),
|
||||
),
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
32
jubeatools/formats/jubeat_analyser/tests/memo2/example2.py
Normal file
32
jubeatools/formats/jubeat_analyser/tests/memo2/example2.py
Normal file
@ -0,0 +1,32 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from jubeatools.song import *
|
||||
|
||||
data = (
|
||||
Song(
|
||||
metadata=Metadata(
|
||||
title="",
|
||||
artist="",
|
||||
audio=Path(""),
|
||||
cover=Path(""),
|
||||
preview=None,
|
||||
preview_file=None,
|
||||
),
|
||||
charts={
|
||||
"BSC": Chart(
|
||||
level=Decimal("0.0"),
|
||||
timing=Timing(
|
||||
events=[
|
||||
BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")),
|
||||
BPMEvent(time=Fraction(5, 1), BPM=Decimal("1.000")),
|
||||
],
|
||||
beat_zero_offset=Decimal("0.000"),
|
||||
),
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
32
jubeatools/formats/jubeat_analyser/tests/memo2/example3.py
Normal file
32
jubeatools/formats/jubeat_analyser/tests/memo2/example3.py
Normal file
@ -0,0 +1,32 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from jubeatools.song import *
|
||||
|
||||
data = (
|
||||
Song(
|
||||
metadata=Metadata(
|
||||
title="",
|
||||
artist="",
|
||||
audio=Path(""),
|
||||
cover=Path(""),
|
||||
preview=None,
|
||||
preview_file=None,
|
||||
),
|
||||
charts={
|
||||
"BSC": Chart(
|
||||
level=Decimal("0.0"),
|
||||
timing=Timing(
|
||||
events=[
|
||||
BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")),
|
||||
BPMEvent(time=Fraction(8, 1), BPM=Decimal("1.000")),
|
||||
],
|
||||
beat_zero_offset=Decimal("0.000"),
|
||||
),
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
@ -2,8 +2,12 @@ from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import List, Union
|
||||
|
||||
from hypothesis import given
|
||||
from hypothesis import example, given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from jubeatools.formats import Format
|
||||
from jubeatools.formats.jubeat_analyser.memo2.dump import _dump_memo2_chart
|
||||
from jubeatools.formats.jubeat_analyser.memo2.load import Memo2Parser
|
||||
from jubeatools.song import (
|
||||
BeatsTime,
|
||||
BPMEvent,
|
||||
@ -12,18 +16,19 @@ from jubeatools.song import (
|
||||
Metadata,
|
||||
NotePosition,
|
||||
SecondsTime,
|
||||
Song,
|
||||
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
|
||||
from ..test_utils import load_and_dump_then_check, memo_compatible_song
|
||||
from . import example1, example2, example3
|
||||
|
||||
|
||||
@given(notes_strat(NoteOption.LONGS))
|
||||
def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None:
|
||||
def test_that_notes_roundtrip(notes: List[Union[TapNote, LongNote]]) -> None:
|
||||
timing = Timing(
|
||||
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
|
||||
)
|
||||
@ -41,3 +46,11 @@ def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None:
|
||||
parser.finish_last_few_notes()
|
||||
actual = set(parser.notes())
|
||||
assert notes == actual
|
||||
|
||||
|
||||
@given(memo_compatible_song(), st.booleans())
|
||||
@example(*example1.data)
|
||||
@example(*example2.data)
|
||||
@example(*example3.data)
|
||||
def test_that_full_chart_roundtrips(song: Song, circle_free: bool) -> None:
|
||||
load_and_dump_then_check(Format.MEMO_2, song, circle_free)
|
@ -4,7 +4,7 @@ import pytest
|
||||
|
||||
from jubeatools.song import BeatsTime, LongNote, NotePosition, TapNote
|
||||
|
||||
from ..mono_column.load import MonoColumnParser
|
||||
from jubeatools.formats.jubeat_analyser.mono_column.load import MonoColumnParser
|
||||
|
||||
|
||||
def compare_chart_notes(
|
@ -14,17 +14,22 @@ from jubeatools.song import (
|
||||
SecondsTime,
|
||||
TapNote,
|
||||
Timing,
|
||||
Song
|
||||
)
|
||||
from jubeatools.testutils.strategies import NoteOption, long_note
|
||||
from jubeatools.testutils.strategies import notes as notes_strat
|
||||
from jubeatools.testutils.strategies import tap_note
|
||||
|
||||
from ..mono_column.dump import _dump_mono_column_chart
|
||||
from ..mono_column.load import MonoColumnParser
|
||||
from jubeatools.formats import Format
|
||||
from jubeatools.formats.jubeat_analyser.mono_column.dump import _dump_mono_column_chart
|
||||
from jubeatools.formats.jubeat_analyser.mono_column.load import MonoColumnParser
|
||||
|
||||
|
||||
from ..test_utils import load_and_dump_then_check, memo_compatible_song
|
||||
|
||||
|
||||
@given(st.sets(tap_note(), min_size=1, max_size=100))
|
||||
def test_tap_notes(notes: Set[TapNote]) -> None:
|
||||
def test_that_a_set_of_tap_notes_roundtrip(notes: Set[TapNote]) -> None:
|
||||
timing = Timing(
|
||||
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
|
||||
)
|
||||
@ -44,7 +49,7 @@ def test_tap_notes(notes: Set[TapNote]) -> None:
|
||||
|
||||
|
||||
@given(long_note())
|
||||
def test_single_long_note(note: LongNote) -> None:
|
||||
def test_that_a_single_long_note_roundtrips(note: LongNote) -> None:
|
||||
timing = Timing(
|
||||
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
|
||||
)
|
||||
@ -60,7 +65,7 @@ def test_single_long_note(note: LongNote) -> None:
|
||||
|
||||
|
||||
@given(notes_strat(NoteOption.LONGS))
|
||||
def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None:
|
||||
def test_that_many_notes_roundtrip(notes: List[Union[TapNote, LongNote]]) -> None:
|
||||
timing = Timing(
|
||||
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
|
||||
)
|
||||
@ -77,3 +82,8 @@ def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None:
|
||||
parser.load_line(line)
|
||||
actual = set(parser.notes())
|
||||
assert notes == actual
|
||||
|
||||
|
||||
@given(memo_compatible_song(), st.booleans())
|
||||
def test_that_full_chart_roundtrips(song: Song, circle_free: bool) -> None:
|
||||
load_and_dump_then_check(Format.MONO_COLUMN, song, circle_free)
|
11
jubeatools/formats/jubeat_analyser/tests/test_command.py
Normal file
11
jubeatools/formats/jubeat_analyser/tests/test_command.py
Normal file
@ -0,0 +1,11 @@
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from ..command import dump_value, parse_value
|
||||
|
||||
|
||||
@given(st.text())
|
||||
def test_that_strings_roundtrip(expected: str) -> None:
|
||||
dumped = dump_value(expected)
|
||||
actual = parse_value(dumped)
|
||||
assert expected == actual
|
@ -1,43 +0,0 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import List, Union
|
||||
|
||||
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: List[Union[TapNote, LongNote]]) -> None:
|
||||
timing = Timing(
|
||||
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
|
||||
)
|
||||
chart = Chart(
|
||||
level=Decimal(0),
|
||||
timing=timing,
|
||||
notes=sorted(notes, key=lambda n: (n.time, n.position)),
|
||||
)
|
||||
metadata = Metadata("", "", Path(""), Path(""))
|
||||
string_io = _dump_memo1_chart("", chart, metadata, timing)
|
||||
chart_text = string_io.getvalue()
|
||||
parser = Memo1Parser()
|
||||
for line in chart_text.split("\n"):
|
||||
parser.load_line(line)
|
||||
parser.finish_last_few_notes()
|
||||
actual = set(parser.notes())
|
||||
assert notes == actual
|
55
jubeatools/formats/jubeat_analyser/tests/test_utils.py
Normal file
55
jubeatools/formats/jubeat_analyser/tests/test_utils.py
Normal file
@ -0,0 +1,55 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from hypothesis import note as hypothesis_note
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from jubeatools import song
|
||||
from jubeatools.formats import DUMPERS, LOADERS, Format
|
||||
from jubeatools.formats.guess import guess_format
|
||||
from jubeatools.testutils import strategies as jbst
|
||||
from jubeatools.testutils.typing import DrawFunc
|
||||
|
||||
|
||||
@st.composite
|
||||
def memo_compatible_metadata(draw: DrawFunc) -> song.Metadata:
|
||||
text_strat = st.text(alphabet=st.characters(min_codepoint=0x20, max_codepoint=0x7E))
|
||||
metadata: song.Metadata = draw(
|
||||
jbst.metadata(text_strat=text_strat, path_start=text_strat)
|
||||
)
|
||||
metadata.preview = None
|
||||
metadata.preview_file = None
|
||||
return metadata
|
||||
|
||||
|
||||
@st.composite
|
||||
def memo_compatible_song(draw: DrawFunc) -> song.Song:
|
||||
"""Memo only supports one difficulty per file"""
|
||||
diff = draw(st.sampled_from(["BSC", "ADV", "EXT"]))
|
||||
chart = draw(
|
||||
jbst.chart(
|
||||
timing_strat=jbst.timing_info(bpm_changes=True),
|
||||
notes_strat=jbst.notes(jbst.NoteOption.LONGS),
|
||||
)
|
||||
)
|
||||
metadata: song.Metadata = draw(memo_compatible_metadata())
|
||||
return song.Song(
|
||||
metadata=metadata,
|
||||
charts={diff: chart},
|
||||
)
|
||||
|
||||
|
||||
def load_and_dump_then_check(f: Format, song: song.Song, circle_free: bool) -> None:
|
||||
loader = LOADERS[f]
|
||||
dumper = DUMPERS[f]
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt") as dst:
|
||||
path = Path(dst.name)
|
||||
files = dumper(song, path, circle_free=circle_free)
|
||||
assert len(files) == 1
|
||||
bytes_ = files.popitem()[1]
|
||||
hypothesis_note(f"Chart file :\n{bytes_.decode('shift-jis-2004')}")
|
||||
dst.write(bytes_)
|
||||
dst.flush()
|
||||
assert guess_format(path) == f
|
||||
recovered_song = loader(path)
|
||||
assert recovered_song == song
|
@ -4,6 +4,7 @@ Hypothesis strategies to generate notes and charts
|
||||
from decimal import Decimal
|
||||
from enum import Enum, Flag, auto
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, TypeVar, Union
|
||||
|
||||
import hypothesis.strategies as st
|
||||
@ -184,7 +185,14 @@ def get_bpm_change_time(b: BPMEvent) -> BeatsTime:
|
||||
|
||||
@st.composite
|
||||
def chart(draw: DrawFunc, timing_strat: Any, notes_strat: Any) -> Chart:
|
||||
level = draw(st.integers(min_value=0))
|
||||
level = Decimal(
|
||||
draw(
|
||||
st.one_of(
|
||||
st.integers(min_value=0),
|
||||
st.decimals(min_value=0, max_value=10.9, places=1),
|
||||
)
|
||||
)
|
||||
)
|
||||
timing = draw(timing_strat)
|
||||
notes = draw(notes_strat)
|
||||
return Chart(
|
||||
@ -206,13 +214,18 @@ def preview(draw: DrawFunc) -> Preview:
|
||||
|
||||
|
||||
@st.composite
|
||||
def metadata(draw: DrawFunc) -> Metadata:
|
||||
def metadata(
|
||||
draw: DrawFunc,
|
||||
text_strat: st.SearchStrategy[str] = st.text(),
|
||||
path_start: st.SearchStrategy[str] = st.text(),
|
||||
) -> Metadata:
|
||||
return Metadata(
|
||||
title=draw(st.text()),
|
||||
artist=draw(st.text()),
|
||||
audio=draw(st.text()),
|
||||
cover=draw(st.text()),
|
||||
title=draw(text_strat),
|
||||
artist=draw(text_strat),
|
||||
audio=Path(draw(path_start)),
|
||||
cover=Path(draw(path_start)),
|
||||
preview=draw(st.one_of(st.none(), preview())),
|
||||
preview_file=draw(path_start),
|
||||
)
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user