1
0
mirror of synced 2024-12-04 19:17:55 +01:00

Fix a lot of jubeat analyser bugs

This commit is contained in:
Stepland 2021-05-03 23:10:48 +02:00
parent 22f8b3c33f
commit 9e0c4f5c2b
30 changed files with 627 additions and 196 deletions

View File

@ -1,6 +1,14 @@
# Unreleased # Unreleased
## Fixed ## 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 # v0.1.3
## Changed ## Changed

View File

@ -42,7 +42,7 @@ command_grammar = Grammar(
equals_value = ws "=" ws value equals_value = ws "=" ws value
value = value_in_quotes / number value = value_in_quotes / number
value_in_quotes = '"' quoted_value '"' value_in_quotes = '"' quoted_value '"'
quoted_value = ~r"[^\"]*" quoted_value = ~r"([^\"\\]|\\\"|\\\\)*"
number = ~r"-?\d+(\.\d+)?" number = ~r"-?\d+(\.\d+)?"
ws = ~r"[\t ]*" ws = ~r"[\t ]*"
comment = ~r"//.*" comment = ~r"//.*"
@ -76,7 +76,7 @@ class CommandVisitor(NodeVisitor):
self.key = letter.text self.key = letter.text
def visit_quoted_value(self, node: Node, visited_children: List[Node]) -> None: 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: def visit_number(self, node: Node, visited_children: List[Node]) -> None:
self.value = node.text self.value = node.text
@ -115,8 +115,38 @@ def dump_command(key: str, value: Any = None) -> str:
if isinstance(value, Number): if isinstance(value, Number):
value_part = f"={value}" value_part = f"={value}"
elif value is not None: elif value is not None:
value_part = f'="{value}"' escaped = dump_value(str(value))
value_part = f'="{escaped}"'
else: else:
value_part = "" value_part = ""
return key_part + 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)

View File

@ -132,7 +132,7 @@ class SortedDefaultDict(SortedDict, Generic[K, V]):
@dataclass @dataclass
class _JubeatAnalyerDumpedSection: class _JubeatAnalyerDumpedSection:
current_beat: BeatsTime current_beat: BeatsTime
length: Decimal = Decimal(4) length: BeatsTime = BeatsTime(4)
commands: Dict[str, Optional[str]] = field(default_factory=dict) commands: Dict[str, Optional[str]] = field(default_factory=dict)
symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict) symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict)
symbols: 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 = sections[BeatsTime(0)].commands
header["o"] = int(timing.beat_zero_offset * 1000) header["o"] = int(timing.beat_zero_offset * 1000)
header["lev"] = int(chart.level) header["lev"] = Decimal(chart.level)
header["dif"] = DIFFICULTIES.get(difficulty, 3) header["dif"] = DIFFICULTIES.get(difficulty, 3)
if metadata.audio: if metadata.audio is not None:
header["m"] = metadata.audio header["m"] = metadata.audio
if metadata.title: if metadata.title is not None:
header["title"] = metadata.title header["title"] = metadata.title
if metadata.artist: if metadata.artist is not None:
header["artist"] = metadata.artist header["artist"] = metadata.artist
if metadata.cover: if metadata.cover is not None:
header["jacket"] = metadata.cover header["jacket"] = metadata.cover
if metadata.preview is not None: if metadata.preview is not None:
header["prevpos"] = int(metadata.preview.start * 1000) header["prevpos"] = int(metadata.preview.start * 1000)

View File

@ -35,10 +35,10 @@ from .symbols import (
DIFFICULTIES = {1: "BSC", 2: "ADV", 3: "EXT"} 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 = { CIRCLE_FREE_TO_BEATS_TIME = {
c: Decimal("0.25") * i for i, c in enumerate(CIRCLE_FREE_SYMBOLS) 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)) 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: class JubeatAnalyserParser:
def __init__(self) -> None: def __init__(self) -> None:
self.music: Optional[str] = None self.music: Optional[str] = None
self.symbols = deepcopy(SYMBOL_TO_DECIMAL_TIME) self.symbols = deepcopy(SYMBOL_TO_BEATS_TIME)
self.section_starting_beat = Decimal("0") self.section_starting_beat = BeatsTime(0)
self.current_tempo = Decimal(120) self.current_tempo = Decimal(120)
self.timing_events: List[BPMEvent] = [] self.timing_events: List[BPMEvent] = []
self.offset = 0 self.offset = 0
self.beats_per_section = Decimal(4) self.beats_per_section = BeatsTime(4)
self.bytes_per_panel = 2 self.bytes_per_panel = 2
self.level = 1 self.level = Decimal(1)
self.difficulty: Optional[str] = None self.difficulty: Optional[str] = None
self.title: Optional[str] = None self.title: Optional[str] = None
self.artist: Optional[str] = None self.artist: Optional[str] = None
@ -329,7 +329,7 @@ class JubeatAnalyserParser:
method() method()
def do_b(self, value: str) -> None: 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: def do_m(self, value: str) -> None:
self.music = value self.music = value
@ -344,7 +344,7 @@ class JubeatAnalyserParser:
self.current_tempo = Decimal(value) self.current_tempo = Decimal(value)
self.timing_events.append( self.timing_events.append(
BPMEvent( BPMEvent(
BeatsTime(self.section_starting_beat), time=self._current_beat(),
BPM=self.current_tempo, BPM=self.current_tempo,
) )
) )
@ -356,7 +356,7 @@ class JubeatAnalyserParser:
do_ph = do_pw do_ph = do_pw
def do_lev(self, value: str) -> None: def do_lev(self, value: str) -> None:
self.level = int(value) self.level = Decimal(value)
def do_dif(self, value: str) -> None: def do_dif(self, value: str) -> None:
dif = int(value) dif = int(value)
@ -430,7 +430,7 @@ class JubeatAnalyserParser:
f"{self.beats_per_section} beats, a symbol cannot happen " f"{self.beats_per_section} beats, a symbol cannot happen "
f"afterwards at {timing}" f"afterwards at {timing}"
) )
self.symbols[symbol] = timing self.symbols[symbol] = decimal_to_beats(timing)
def is_short_line(self, line: str) -> bool: def is_short_line(self, line: str) -> bool:
return len(line.encode("shift-jis-2004")) < self.bytes_per_panel * 4 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" f"in mono-column format (1列形式) there should be no {format_} line"
) )
def _current_beat(self) -> BeatsTime:
raise NotImplementedError
@dataclass @dataclass
class DoubleColumnFrame: class DoubleColumnFrame:

View File

@ -54,7 +54,7 @@ class Frame:
positions: Dict[NotePosition, str] = field(default_factory=dict) positions: Dict[NotePosition, str] = field(default_factory=dict)
bars: Dict[int, Dict[int, 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() self.raise_if_unfit()
for pos, bar in zip_longest(self.dump_positions(), self.dump_bars(length)): for pos, bar in zip_longest(self.dump_positions(), self.dump_bars(length)):
if bar is None: if bar is None:
@ -84,7 +84,7 @@ class Frame:
for x in range(4) for x in range(4)
) )
def dump_bars(self, length: Decimal) -> Iterator[str]: def dump_bars(self, length: BeatsTime) -> Iterator[str]:
all_bars = [] all_bars = []
for i in range(ceil(length * 4)): for i in range(ceil(length * 4)):
bar_index = i // 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( def _dump_memo_chart(
difficulty: str, difficulty: str,
chart: Chart, chart: Chart,
@ -257,8 +261,9 @@ def _dump_memo_chart(
file = StringIO() file = StringIO()
file.write(f"// Converted using jubeatools {__version__}\n") file.write(f"// Converted using jubeatools {__version__}\n")
file.write(f"// https://github.com/Stepland/jubeatools\n\n") file.write(f"// https://github.com/Stepland/jubeatools\n\n")
for _, section in sections.items(): file.write(
file.write(section.render(circle_free) + "\n\n") "\n\n".join(section.render(circle_free) for _, section in sections.items())
)
return file return file

View File

@ -13,6 +13,7 @@ from more_itertools import collapse, mark_ends
from parsimonious import Grammar, NodeVisitor, ParseError from parsimonious import Grammar, NodeVisitor, ParseError
from jubeatools.song import ( from jubeatools.song import (
BeatsTime,
Chart, Chart,
LongNote, LongNote,
Metadata, Metadata,
@ -28,7 +29,6 @@ from jubeatools.utils import none_or
from ..command import is_command, parse_command from ..command import is_command, parse_command
from ..files import load_files from ..files import load_files
from ..load_tools import ( from ..load_tools import (
CIRCLE_FREE_TO_DECIMAL_TIME,
CIRCLE_FREE_TO_NOTE_SYMBOL, CIRCLE_FREE_TO_NOTE_SYMBOL,
EMPTY_BEAT_SYMBOLS, EMPTY_BEAT_SYMBOLS,
LONG_ARROWS, LONG_ARROWS,
@ -53,18 +53,18 @@ from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
class MemoFrame(DoubleColumnFrame): class MemoFrame(DoubleColumnFrame):
@property @property
def duration(self) -> Decimal: def duration(self) -> BeatsTime:
res = 0 # This is wrong for the last frame in a section if the section has a
for t in self.timing_part: # decimal beat length that's not a multiple of 1/4
res += len(t) number_of_symbols = sum(len(t) for t in self.timing_part)
return Decimal("0.25") * res return BeatsTime("1/4") * number_of_symbols
@dataclass @dataclass
class MemoLoadedSection: class MemoLoadedSection:
frames: List[MemoFrame] frames: List[MemoFrame]
symbols: Dict[str, Decimal] symbols: Dict[str, BeatsTime]
length: Decimal length: BeatsTime
tempo: Decimal tempo: Decimal
def __str__(self) -> str: def __str__(self) -> str:
@ -87,15 +87,26 @@ class MemoParser(JubeatAnalyserParser):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.current_chart_lines: List[DoubleColumnChartLine] = [] self.current_chart_lines: List[DoubleColumnChartLine] = []
self.symbols: Dict[str, Decimal] = {} self.current_frames: List[MemoFrame] = []
self.frames: List[MemoFrame] = []
self.sections: List[MemoLoadedSection] = [] self.sections: List[MemoLoadedSection] = []
def do_memo(self) -> None: 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: 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") raise ValueError("jubeatools does not handle changes of #bpp halfway")
else: else:
self._do_bpp(value) self._do_bpp(value)
@ -106,10 +117,23 @@ class MemoParser(JubeatAnalyserParser):
if len(self.current_chart_lines) == 4: if len(self.current_chart_lines) == 4:
self._push_frame() self._push_frame()
def _frames_duration(self) -> Decimal: def _frames_duration(self) -> BeatsTime:
return sum((frame.duration for frame in self.frames), start=Decimal(0)) 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: def _push_frame(self) -> None:
"""Take all chart lines and push them to a new frame"""
position_part = [ position_part = [
self._split_chart_line(memo_line.position) self._split_chart_line(memo_line.position)
for memo_line in self.current_chart_lines for memo_line in self.current_chart_lines
@ -127,19 +151,25 @@ class MemoParser(JubeatAnalyserParser):
# then the current frame starts a new section # then the current frame starts a new section
self._push_section() self._push_section()
self.frames.append(frame) self.current_frames.append(frame)
self.current_chart_lines = [] self.current_chart_lines = []
def _push_section(self) -> None: 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( self.sections.append(
MemoLoadedSection( MemoLoadedSection(
frames=self.frames, frames=self.current_frames,
symbols=deepcopy(self.symbols), symbols=deepcopy(self.symbols),
length=self.beats_per_section, length=self.beats_per_section,
tempo=self.current_tempo, tempo=self.current_tempo,
) )
) )
self.frames = [] self.current_frames = []
self.section_starting_beat += self.beats_per_section self.section_starting_beat += self.beats_per_section
def finish_last_few_notes(self) -> None: def finish_last_few_notes(self) -> None:
@ -179,20 +209,22 @@ class MemoParser(JubeatAnalyserParser):
def _iter_frames( def _iter_frames(
self, self,
) -> Iterator[Tuple[Mapping[str, Decimal], MemoFrame, Decimal, MemoLoadedSection]]: ) -> Iterator[
Tuple[Mapping[str, BeatsTime], MemoFrame, BeatsTime, MemoLoadedSection]
]:
"""iterate over tuples of """iterate over tuples of
currently_defined_symbols, frame_starting_beat, frame, section_starting_beat, section""" currently_defined_symbols, frame_starting_beat, frame, section_starting_beat, section"""
local_symbols: Dict[str, Decimal] = {} local_symbols: Dict[str, BeatsTime] = {}
section_starting_beat = Decimal(0) section_starting_beat = BeatsTime(0)
for section in self.sections: for section in self.sections:
frame_starting_beat = Decimal(0) frame_starting_beat = BeatsTime(0)
for i, frame in enumerate(section.frames): for i, frame in enumerate(section.frames):
if frame.timing_part: if frame.timing_part:
frame_starting_beat = sum( 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 = { 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)) for i, symbol in enumerate(collapse(frame.timing_part))
if symbol not in EMPTY_BEAT_SYMBOLS if symbol not in EMPTY_BEAT_SYMBOLS
} }
@ -232,7 +264,7 @@ class MemoParser(JubeatAnalyserParser):
continue continue
should_skip.add(pos) 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) yield unfinished_long.ends_at(note_time)
unfinished_longs = { unfinished_longs = {
@ -253,7 +285,7 @@ class MemoParser(JubeatAnalyserParser):
should_skip.add(note_pos) should_skip.add(note_pos)
symbol = frame.position_part[note_pos.y][note_pos.x] symbol = frame.position_part[note_pos.y][note_pos.x]
symbol_time = currently_defined_symbols[symbol] 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( unfinished_longs[note_pos] = UnfinishedLongNote(
time=note_time, position=note_pos, tail_tip=arrow_pos time=note_time, position=note_pos, tail_tip=arrow_pos
) )
@ -268,7 +300,7 @@ class MemoParser(JubeatAnalyserParser):
symbol_time = currently_defined_symbols[symbol] symbol_time = currently_defined_symbols[symbol]
except KeyError: except KeyError:
continue continue
note_time = decimal_to_beats(section_starting_beat + symbol_time) note_time = section_starting_beat + symbol_time
yield TapNote(note_time, position) yield TapNote(note_time, position)
def _iter_notes_without_longs(self) -> Iterator[TapNote]: def _iter_notes_without_longs(self) -> Iterator[TapNote]:
@ -285,7 +317,7 @@ class MemoParser(JubeatAnalyserParser):
symbol_time = currently_defined_symbols[symbol] symbol_time = currently_defined_symbols[symbol]
except KeyError: except KeyError:
continue continue
note_time = decimal_to_beats(section_starting_beat + symbol_time) note_time = section_starting_beat + symbol_time
position = NotePosition(x, y) position = NotePosition(x, y)
yield TapNote(note_time, position) yield TapNote(note_time, position)
@ -296,7 +328,7 @@ def _load_memo_file(lines: List[str]) -> Song:
try: try:
parser.load_line(raw_line) parser.load_line(raw_line)
except Exception as e: 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() parser.finish_last_few_notes()
metadata = Metadata( metadata = Metadata(

View File

@ -55,7 +55,7 @@ class Frame:
positions: Dict[NotePosition, str] = field(default_factory=dict) positions: Dict[NotePosition, str] = field(default_factory=dict)
bars: Dict[int, List[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 # Check that bars are contiguous
for a, b in windowed(sorted(self.bars), 2): for a, b in windowed(sorted(self.bars), 2):
if a is not None and b is not None: if a is not None and b is not None:
@ -77,7 +77,7 @@ class Frame:
for x in range(4) 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)): for i in range(ceil(length)):
if i in self.bars: if i in self.bars:
yield f"|{''.join(self.bars[i])}|" yield f"|{''.join(self.bars[i])}|"
@ -110,7 +110,9 @@ class Memo1DumpedSection(JubeatAnalyserDumpedSection):
symbols_iterator = iter(NOTE_SYMBOLS) symbols_iterator = iter(NOTE_SYMBOLS)
for bar_index in range(ceil(self.length)): for bar_index in range(ceil(self.length)):
notes = notes_by_bar.get(bar_index, []) 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: if bar_length < 3:
bar_length = 4 bar_length = 4
bar_dict: Dict[int, str] = {} 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( def _dump_memo1_chart(
difficulty: str, difficulty: str,
chart: Chart, chart: Chart,
@ -236,8 +242,9 @@ def _dump_memo1_chart(
file = StringIO() file = StringIO()
file.write(f"// Converted using jubeatools {__version__}\n") file.write(f"// Converted using jubeatools {__version__}\n")
file.write(f"// https://github.com/Stepland/jubeatools\n\n") file.write(f"// https://github.com/Stepland/jubeatools\n\n")
for _, section in sections.items(): file.write(
file.write(section.render(circle_free) + "\n") "\n\n".join(section.render(circle_free) for _, section in sections.items())
)
return file return file

View File

@ -29,7 +29,6 @@ from jubeatools.utils import none_or
from ..command import is_command, parse_command from ..command import is_command, parse_command
from ..files import load_files from ..files import load_files
from ..load_tools import ( from ..load_tools import (
CIRCLE_FREE_TO_DECIMAL_TIME,
CIRCLE_FREE_TO_NOTE_SYMBOL, CIRCLE_FREE_TO_NOTE_SYMBOL,
EMPTY_BEAT_SYMBOLS, EMPTY_BEAT_SYMBOLS,
LONG_ARROWS, LONG_ARROWS,
@ -54,14 +53,16 @@ from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
class Memo1Frame(DoubleColumnFrame): class Memo1Frame(DoubleColumnFrame):
@property @property
def duration(self) -> Decimal: def duration(self) -> BeatsTime:
return Decimal(len(self.timing_part)) # 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 @dataclass
class Memo1LoadedSection: class Memo1LoadedSection:
frames: List[Memo1Frame] frames: List[Memo1Frame]
length: Decimal length: BeatsTime
tempo: Decimal tempo: Decimal
def __str__(self) -> str: def __str__(self) -> str:
@ -82,14 +83,26 @@ class Memo1Parser(JubeatAnalyserParser):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.current_chart_lines: List[DoubleColumnChartLine] = [] self.current_chart_lines: List[DoubleColumnChartLine] = []
self.frames: List[Memo1Frame] = [] self.current_frames: List[Memo1Frame] = []
self.sections: List[Memo1LoadedSection] = [] self.sections: List[Memo1LoadedSection] = []
def do_memo1(self) -> None: 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: 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") raise ValueError("jubeatools does not handle changes of #bpp halfway")
else: else:
self._do_bpp(value) self._do_bpp(value)
@ -100,8 +113,20 @@ class Memo1Parser(JubeatAnalyserParser):
if len(self.current_chart_lines) == 4: if len(self.current_chart_lines) == 4:
self._push_frame() self._push_frame()
def _frames_duration(self) -> Decimal: def _frames_duration(self) -> BeatsTime:
return sum((frame.duration for frame in self.frames), start=Decimal(0)) 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: def _push_frame(self) -> None:
position_part = [ position_part = [
@ -121,18 +146,24 @@ class Memo1Parser(JubeatAnalyserParser):
# then the current frame starts a new section # then the current frame starts a new section
self._push_section() self._push_section()
self.frames.append(frame) self.current_frames.append(frame)
self.current_chart_lines = [] self.current_chart_lines = []
def _push_section(self) -> None: 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( self.sections.append(
Memo1LoadedSection( Memo1LoadedSection(
frames=deepcopy(self.frames), frames=deepcopy(self.current_frames),
length=self.beats_per_section, length=self.beats_per_section,
tempo=self.current_tempo, tempo=self.current_tempo,
) )
) )
self.frames = [] self.current_frames = []
self.section_starting_beat += self.beats_per_section self.section_starting_beat += self.beats_per_section
def finish_last_few_notes(self) -> None: def finish_last_few_notes(self) -> None:
@ -170,23 +201,23 @@ class Memo1Parser(JubeatAnalyserParser):
def _iter_frames( def _iter_frames(
self, self,
) -> Iterator[ ) -> Iterator[
Tuple[Mapping[str, BeatsTime], Memo1Frame, Decimal, Memo1LoadedSection] Tuple[Mapping[str, BeatsTime], Memo1Frame, BeatsTime, Memo1LoadedSection]
]: ]:
"""iterate over tuples of """iterate over tuples of
currently_defined_symbols, frame, section_starting_beat, section""" currently_defined_symbols, frame, section_starting_beat, section"""
local_symbols = {} local_symbols = {}
section_starting_beat = Decimal(0) section_starting_beat = BeatsTime(0)
for section in self.sections: for section in self.sections:
frame_starting_beat = Decimal(0) frame_starting_beat = BeatsTime(0)
for i, frame in enumerate(section.frames): for i, frame in enumerate(section.frames):
if frame.timing_part: if frame.timing_part:
frame_starting_beat = sum( 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 = { local_symbols = {
symbol: BeatsTime(symbol_index, len(bar)) symbol: BeatsTime(symbol_index, len(bar))
+ bar_index + bar_index
+ decimal_to_beats(frame_starting_beat) + frame_starting_beat
for bar_index, bar in enumerate(frame.timing_part) for bar_index, bar in enumerate(frame.timing_part)
for symbol_index, symbol in enumerate(bar) for symbol_index, symbol in enumerate(bar)
if symbol not in EMPTY_BEAT_SYMBOLS if symbol not in EMPTY_BEAT_SYMBOLS
@ -226,7 +257,7 @@ class Memo1Parser(JubeatAnalyserParser):
continue continue
should_skip.add(pos) 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) yield unfinished_long.ends_at(note_time)
unfinished_longs = { unfinished_longs = {
@ -247,7 +278,7 @@ class Memo1Parser(JubeatAnalyserParser):
should_skip.add(note_pos) should_skip.add(note_pos)
symbol = frame.position_part[note_pos.y][note_pos.x] symbol = frame.position_part[note_pos.y][note_pos.x]
symbol_time = currently_defined_symbols[symbol] 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( unfinished_longs[note_pos] = UnfinishedLongNote(
time=note_time, position=note_pos, tail_tip=arrow_pos time=note_time, position=note_pos, tail_tip=arrow_pos
) )
@ -262,7 +293,7 @@ class Memo1Parser(JubeatAnalyserParser):
symbol_time = currently_defined_symbols[symbol] symbol_time = currently_defined_symbols[symbol]
except KeyError: except KeyError:
continue continue
note_time = decimal_to_beats(section_starting_beat) + symbol_time note_time = section_starting_beat + symbol_time
yield TapNote(note_time, position) yield TapNote(note_time, position)
def _iter_notes_without_longs(self) -> Iterator[TapNote]: def _iter_notes_without_longs(self) -> Iterator[TapNote]:
@ -279,7 +310,7 @@ class Memo1Parser(JubeatAnalyserParser):
symbol_time = currently_defined_symbols[symbol] symbol_time = currently_defined_symbols[symbol]
except KeyError: except KeyError:
continue continue
note_time = decimal_to_beats(section_starting_beat) + symbol_time note_time = section_starting_beat + symbol_time
position = NotePosition(x, y) position = NotePosition(x, y)
yield TapNote(note_time, position) yield TapNote(note_time, position)

View File

@ -292,8 +292,8 @@ def _dump_memo2_chart(
# Timing events # Timing events
sections[0].events.append(StopEvent(BeatsTime(0), timing.beat_zero_offset)) sections[0].events.append(StopEvent(BeatsTime(0), timing.beat_zero_offset))
for event in timing_events: for event in timing_events:
section_index = event.time // 4 section_beat = event.time - (event.time % 4)
sections[section_index].events.append(event) sections[section_beat].events.append(event)
# Fill sections with notes # Fill sections with notes
for key, next_key in windowed(chain(sections.keys(), [None]), 2): 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") file.write(f"// https://github.com/Stepland/jubeatools\n\n")
# Header # 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") 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") 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") 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") 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") file.write(dump_command("jacket", metadata.cover) + "\n")
if metadata.preview is not None: if metadata.preview is not None:
file.write(dump_command("prevpos", int(metadata.preview.start * 1000)) + "\n") file.write(dump_command("prevpos", int(metadata.preview.start * 1000)) + "\n")

View File

@ -31,7 +31,6 @@ from jubeatools.utils import none_or
from ..command import is_command, parse_command from ..command import is_command, parse_command
from ..files import load_files from ..files import load_files
from ..load_tools import ( from ..load_tools import (
CIRCLE_FREE_TO_DECIMAL_TIME,
CIRCLE_FREE_TO_NOTE_SYMBOL, CIRCLE_FREE_TO_NOTE_SYMBOL,
EMPTY_BEAT_SYMBOLS, EMPTY_BEAT_SYMBOLS,
LONG_ARROWS, LONG_ARROWS,
@ -98,6 +97,13 @@ class Memo2ChartLine:
position: str position: str
timing: Optional[List[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( memo2_chart_line_grammar = Grammar(
r""" r"""
@ -185,26 +191,27 @@ class Memo2Parser(JubeatAnalyserParser):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.current_chart_lines: List[Memo2ChartLine] = [] self.current_chart_lines: List[Memo2ChartLine] = []
self.current_beat = BeatsTime(0)
self.frames: List[Memo2Frame] = [] self.frames: List[Memo2Frame] = []
def do_b(self, value: str) -> None: def do_b(self, value: str) -> None:
raise ValueError( raise RuntimeError(
"beat command (b=...) found, this commands cannot be used in #memo2 files" "beat command (b=...) found, this commands cannot be used in #memo2 files"
) )
def do_t(self, value: str) -> None: def do_t(self, value: str) -> None:
if self.frames: if self.frames:
raise ValueError( raise RuntimeError(
"tempo command (t=...) found outside of the file header, " "tempo command (t=...) found outside of the file header, "
"this should not happen in #memo2 files" "this should not happen in #memo2 files"
) )
else: 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: def do_r(self, value: str) -> None:
if self.frames: if self.frames:
raise ValueError( raise RuntimeError(
"offset increase command (r=...) found outside of the file " "offset increase command (r=...) found outside of the file "
"header, this is not supported by jubeatools" "header, this is not supported by jubeatools"
) )
@ -219,7 +226,7 @@ class Memo2Parser(JubeatAnalyserParser):
def do_bpp(self, value: Union[int, str]) -> None: def do_bpp(self, value: Union[int, str]) -> None:
if self.frames: if self.frames:
raise ValueError("jubeatools does not handle changes of #bpp halfway") raise RuntimeError("jubeatools does not handle changes of #bpp halfway")
else: else:
self._do_bpp(value) self._do_bpp(value)
@ -256,10 +263,12 @@ class Memo2Parser(JubeatAnalyserParser):
in_bar_beat += symbol_duration in_bar_beat += symbol_duration
elif isinstance(event, BPM): elif isinstance(event, BPM):
self.timing_events.append( 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): elif isinstance(event, Stop):
time = self.current_beat + in_bar_beat time = self._current_beat() + in_bar_beat
if time != 0: if time != 0:
raise ValueError( raise ValueError(
"Chart contains a pause that's not happening at the " "Chart contains a pause that's not happening at the "
@ -274,9 +283,17 @@ class Memo2Parser(JubeatAnalyserParser):
if len(self.current_chart_lines) == 4: if len(self.current_chart_lines) == 4:
self._push_frame() self._push_frame()
def _current_beat(self) -> BeatsTime:
return self._frames_duration() + self._lines_duration()
def _frames_duration(self) -> BeatsTime: def _frames_duration(self) -> BeatsTime:
return sum((frame.duration for frame in self.frames), start=BeatsTime(0)) 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: def _push_frame(self) -> None:
position_part = [ position_part = [
self._split_chart_line(memo_line.position) self._split_chart_line(memo_line.position)

View File

@ -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( def _dump_mono_column_chart(
difficulty: str, difficulty: str,
chart: Chart, chart: Chart,
@ -150,7 +154,7 @@ def _dump_mono_column_chart(
_raise_if_unfit_for_mono_column(chart, timing, circle_free) _raise_if_unfit_for_mono_column(chart, timing, circle_free)
sections = create_sections_from_chart( sections = create_sections_from_chart(
MonoColumnDumpedSection, chart, difficulty, timing, metadata, circle_free _section_factory, chart, difficulty, timing, metadata, circle_free
) )
# Define extra symbols # Define extra symbols

View File

@ -32,7 +32,7 @@ from jubeatools.utils import none_or
from ..command import is_command, parse_command from ..command import is_command, parse_command
from ..files import load_files from ..files import load_files
from ..load_tools import ( from ..load_tools import (
CIRCLE_FREE_TO_DECIMAL_TIME, CIRCLE_FREE_TO_BEATS_TIME,
LONG_ARROWS, LONG_ARROWS,
LONG_DIRECTION, LONG_DIRECTION,
JubeatAnalyserParser, 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 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 @dataclass
class MonoColumnLoadedSection: class MonoColumnLoadedSection:
""" """
@ -98,8 +93,8 @@ class MonoColumnLoadedSection:
""" """
chart_lines: List[str] chart_lines: List[str]
symbols: Dict[str, Decimal] symbols: Dict[str, BeatsTime]
length: Decimal length: BeatsTime
tempo: Decimal tempo: Decimal
def blocs(self, bpp: int = 2) -> Iterator[List[List[str]]]: def blocs(self, bpp: int = 2) -> Iterator[List[List[str]]]:
@ -120,6 +115,9 @@ class MonoColumnParser(JubeatAnalyserParser):
self.current_chart_lines: List[str] = [] self.current_chart_lines: List[str] = []
self.sections: List[MonoColumnLoadedSection] = [] self.sections: List[MonoColumnLoadedSection] = []
def _current_beat(self) -> BeatsTime:
return self.section_starting_beat
def do_bpp(self, value: Union[int, str]) -> None: def do_bpp(self, value: Union[int, str]) -> None:
if self.sections: if self.sections:
raise ValueError( raise ValueError(
@ -179,8 +177,8 @@ class MonoColumnParser(JubeatAnalyserParser):
def _iter_blocs( def _iter_blocs(
self, self,
) -> Iterator[Tuple[Decimal, MonoColumnLoadedSection, List[List[str]]]]: ) -> Iterator[Tuple[BeatsTime, MonoColumnLoadedSection, List[List[str]]]]:
section_starting_beat = Decimal(0) section_starting_beat = BeatsTime(0)
for section in self.sections: for section in self.sections:
for bloc in section.blocs(): for bloc in section.blocs():
yield section_starting_beat, section, bloc yield section_starting_beat, section, bloc
@ -198,10 +196,8 @@ class MonoColumnParser(JubeatAnalyserParser):
if self.circle_free: if self.circle_free:
if symbol in CIRCLE_FREE_SYMBOLS: if symbol in CIRCLE_FREE_SYMBOLS:
should_skip.add(pos) should_skip.add(pos)
symbol_time = CIRCLE_FREE_TO_DECIMAL_TIME[symbol] symbol_time = CIRCLE_FREE_TO_BEATS_TIME[symbol]
note_time = decimal_to_beats( note_time = section_starting_beat + symbol_time
section_starting_beat + symbol_time
)
yield unfinished_long.ends_at(note_time) yield unfinished_long.ends_at(note_time)
elif symbol in section.symbols: elif symbol in section.symbols:
raise SyntaxError( raise SyntaxError(
@ -212,9 +208,7 @@ class MonoColumnParser(JubeatAnalyserParser):
if symbol in section.symbols: if symbol in section.symbols:
should_skip.add(pos) should_skip.add(pos)
symbol_time = section.symbols[symbol] symbol_time = section.symbols[symbol]
note_time = decimal_to_beats( note_time = section_starting_beat + symbol_time
section_starting_beat + symbol_time
)
yield unfinished_long.ends_at(note_time) yield unfinished_long.ends_at(note_time)
unfinished_longs = { unfinished_longs = {
@ -235,7 +229,7 @@ class MonoColumnParser(JubeatAnalyserParser):
should_skip.add(note_pos) should_skip.add(note_pos)
symbol = bloc[note_pos.y][note_pos.x] symbol = bloc[note_pos.y][note_pos.x]
symbol_time = section.symbols[symbol] 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( unfinished_longs[note_pos] = UnfinishedLongNote(
time=note_time, position=note_pos, tail_tip=arrow_pos time=note_time, position=note_pos, tail_tip=arrow_pos
) )
@ -248,17 +242,17 @@ class MonoColumnParser(JubeatAnalyserParser):
symbol = bloc[y][x] symbol = bloc[y][x]
if symbol in section.symbols: if symbol in section.symbols:
symbol_time = section.symbols[symbol] 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) yield TapNote(note_time, position)
def _iter_notes_without_longs(self) -> Iterator[TapNote]: def _iter_notes_without_longs(self) -> Iterator[TapNote]:
section_starting_beat = Decimal(0) section_starting_beat = BeatsTime(0)
for section in self.sections: for section in self.sections:
for bloc, y, x in product(section.blocs(), range(4), range(4)): for bloc, y, x in product(section.blocs(), range(4), range(4)):
symbol = bloc[y][x] symbol = bloc[y][x]
if symbol in section.symbols: if symbol in section.symbols:
symbol_time = section.symbols[symbol] 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) position = NotePosition(x, y)
yield TapNote(note_time, position) yield TapNote(note_time, position)
section_starting_beat += section.length section_starting_beat += section.length

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

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

View File

@ -1,41 +1,38 @@
import tempfile
from decimal import Decimal from decimal import Decimal
from fractions import Fraction from fractions import Fraction
from pathlib import Path from pathlib import Path
from typing import Set, Union from typing import Set, Union
from hypothesis import example, given 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 import song
from jubeatools.formats.jubeat_analyser.memo.load import MemoParser from jubeatools.formats.enum import Format
from jubeatools.song import ( from jubeatools.formats.guess import guess_format
BeatsTime, from jubeatools.formats.jubeat_analyser.memo.dump import _dump_memo_chart, dump_memo
BPMEvent, from jubeatools.formats.jubeat_analyser.memo.load import MemoParser, load_memo
Chart, from jubeatools.testutils import strategies as jbst
LongNote, from jubeatools.testutils.typing import DrawFunc
Metadata,
NotePosition,
SecondsTime,
TapNote,
Timing,
)
from jubeatools.testutils.strategies import NoteOption
from jubeatools.testutils.strategies import notes as notes_strat
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) @example(example1.notes)
def test_many_notes(notes: Set[Union[TapNote, LongNote]]) -> None: def test_that_notes_roundtrip(notes: Set[Union[song.TapNote, song.LongNote]]) -> None:
timing = Timing( timing = song.Timing(
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) events=[song.BPMEvent(song.BeatsTime(0), Decimal(120))],
beat_zero_offset=song.SecondsTime(0),
) )
chart = Chart( chart = song.Chart(
level=Decimal(0), level=Decimal(0),
timing=timing, timing=timing,
notes=sorted(notes, key=lambda n: (n.time, n.position)), 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) string_io = _dump_memo_chart("", chart, metadata, timing, False)
chart_text = string_io.getvalue() chart_text = string_io.getvalue()
parser = MemoParser() parser = MemoParser()
@ -44,3 +41,10 @@ def test_many_notes(notes: Set[Union[TapNote, LongNote]]) -> None:
parser.finish_last_few_notes() parser.finish_last_few_notes()
actual = set(parser.notes()) actual = set(parser.notes())
assert notes == actual 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)

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

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

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

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

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

View File

@ -2,8 +2,12 @@ from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import List, Union 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 ( from jubeatools.song import (
BeatsTime, BeatsTime,
BPMEvent, BPMEvent,
@ -12,18 +16,19 @@ from jubeatools.song import (
Metadata, Metadata,
NotePosition, NotePosition,
SecondsTime, SecondsTime,
Song,
TapNote, TapNote,
Timing, Timing,
) )
from jubeatools.testutils.strategies import NoteOption from jubeatools.testutils.strategies import NoteOption
from jubeatools.testutils.strategies import notes as notes_strat from jubeatools.testutils.strategies import notes as notes_strat
from ..memo2.dump import _dump_memo2_chart from ..test_utils import load_and_dump_then_check, memo_compatible_song
from ..memo2.load import Memo2Parser from . import example1, example2, example3
@given(notes_strat(NoteOption.LONGS)) @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( timing = Timing(
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) 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() parser.finish_last_few_notes()
actual = set(parser.notes()) actual = set(parser.notes())
assert notes == actual 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)

View File

@ -4,7 +4,7 @@ import pytest
from jubeatools.song import BeatsTime, LongNote, NotePosition, TapNote 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( def compare_chart_notes(

View File

@ -14,17 +14,22 @@ from jubeatools.song import (
SecondsTime, SecondsTime,
TapNote, TapNote,
Timing, Timing,
Song
) )
from jubeatools.testutils.strategies import NoteOption, long_note from jubeatools.testutils.strategies import NoteOption, long_note
from jubeatools.testutils.strategies import notes as notes_strat from jubeatools.testutils.strategies import notes as notes_strat
from jubeatools.testutils.strategies import tap_note from jubeatools.testutils.strategies import tap_note
from ..mono_column.dump import _dump_mono_column_chart from jubeatools.formats import Format
from ..mono_column.load import MonoColumnParser 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)) @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( timing = Timing(
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) 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()) @given(long_note())
def test_single_long_note(note: LongNote) -> None: def test_that_a_single_long_note_roundtrips(note: LongNote) -> None:
timing = Timing( timing = Timing(
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) 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)) @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( timing = Timing(
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) 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) parser.load_line(line)
actual = set(parser.notes()) actual = set(parser.notes())
assert notes == actual 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)

View 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

View File

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

View 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

View File

@ -4,6 +4,7 @@ Hypothesis strategies to generate notes and charts
from decimal import Decimal from decimal import Decimal
from enum import Enum, Flag, auto from enum import Enum, Flag, auto
from itertools import product from itertools import product
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, TypeVar, Union from typing import Any, Callable, Dict, List, Optional, Set, TypeVar, Union
import hypothesis.strategies as st import hypothesis.strategies as st
@ -184,7 +185,14 @@ def get_bpm_change_time(b: BPMEvent) -> BeatsTime:
@st.composite @st.composite
def chart(draw: DrawFunc, timing_strat: Any, notes_strat: Any) -> Chart: 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) timing = draw(timing_strat)
notes = draw(notes_strat) notes = draw(notes_strat)
return Chart( return Chart(
@ -206,13 +214,18 @@ def preview(draw: DrawFunc) -> Preview:
@st.composite @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( return Metadata(
title=draw(st.text()), title=draw(text_strat),
artist=draw(st.text()), artist=draw(text_strat),
audio=draw(st.text()), audio=Path(draw(path_start)),
cover=draw(st.text()), cover=Path(draw(path_start)),
preview=draw(st.one_of(st.none(), preview())), preview=draw(st.one_of(st.none(), preview())),
preview_file=draw(path_start),
) )