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
|
# 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
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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")
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
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 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)
|
||||||
|
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 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)
|
@ -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(
|
@ -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)
|
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 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user