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
## Fixed
- [jubeat-analyser] Raise exception earlier when a mono-column file is detected by the other #memo parsers (based on "--" separator lines)
- [jubeat-analyser]
- Raise exception earlier when a mono-column file is detected by the other #memo parsers (based on "--" separator lines)
- [#memo] [#memo1]
- Fix incorrect handling of mid-chart `t=` and `b=` commands
- Prettify rendering by adding more blank lines between sections
- [#memo1] Fix dumping of chart with bpm changes happening on beat times that aren't multiples of 1/4
- [#memo2]
- Fix parsing of BPM changes
- Fix dumping of BPM changes
# v0.1.3
## Changed

View File

@ -42,7 +42,7 @@ command_grammar = Grammar(
equals_value = ws "=" ws value
value = value_in_quotes / number
value_in_quotes = '"' quoted_value '"'
quoted_value = ~r"[^\"]*"
quoted_value = ~r"([^\"\\]|\\\"|\\\\)*"
number = ~r"-?\d+(\.\d+)?"
ws = ~r"[\t ]*"
comment = ~r"//.*"
@ -76,7 +76,7 @@ class CommandVisitor(NodeVisitor):
self.key = letter.text
def visit_quoted_value(self, node: Node, visited_children: List[Node]) -> None:
self.value = node.text
self.value = parse_value(node.text)
def visit_number(self, node: Node, visited_children: List[Node]) -> None:
self.value = node.text
@ -115,8 +115,38 @@ def dump_command(key: str, value: Any = None) -> str:
if isinstance(value, Number):
value_part = f"={value}"
elif value is not None:
value_part = f'="{value}"'
escaped = dump_value(str(value))
value_part = f'="{escaped}"'
else:
value_part = ""
return key_part + value_part
BACKSLASH = "\\"
def parse_value(escaped: str) -> str:
"""Unescapes a backslash-escaped string"""
res = []
i = 0
while i < len(escaped):
char = escaped[i]
if char == BACKSLASH:
if i + 1 == len(escaped):
raise ValueError("backslash at end of string")
else:
i += 1
res.append(escaped[i])
i += 1
return "".join(res)
ESCAPE_TABLE = str.maketrans({'"': BACKSLASH + '"', BACKSLASH: BACKSLASH + BACKSLASH})
def dump_value(value: str) -> str:
"""backslash-escapes \ and " from a string"""
return value.translate(ESCAPE_TABLE)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,7 +29,6 @@ from jubeatools.utils import none_or
from ..command import is_command, parse_command
from ..files import load_files
from ..load_tools import (
CIRCLE_FREE_TO_DECIMAL_TIME,
CIRCLE_FREE_TO_NOTE_SYMBOL,
EMPTY_BEAT_SYMBOLS,
LONG_ARROWS,
@ -54,14 +53,16 @@ from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
class Memo1Frame(DoubleColumnFrame):
@property
def duration(self) -> Decimal:
return Decimal(len(self.timing_part))
def duration(self) -> BeatsTime:
# This is wrong for the last frame in a section if the section has a
# length in beats that's not an integer
return BeatsTime(len(self.timing_part))
@dataclass
class Memo1LoadedSection:
frames: List[Memo1Frame]
length: Decimal
length: BeatsTime
tempo: Decimal
def __str__(self) -> str:
@ -82,14 +83,26 @@ class Memo1Parser(JubeatAnalyserParser):
def __init__(self) -> None:
super().__init__()
self.current_chart_lines: List[DoubleColumnChartLine] = []
self.frames: List[Memo1Frame] = []
self.current_frames: List[Memo1Frame] = []
self.sections: List[Memo1LoadedSection] = []
def do_memo1(self) -> None:
...
def do_b(self, value: str) -> None:
"""Because of the way the parser works,
b= commands must mark the end of a section to be properly taken into
account"""
if self.current_chart_lines:
raise SyntaxError("Found a b= command before the end of a frame")
if self.current_frames and self._frames_duration() < self.beats_per_section:
raise SyntaxError("Found a b= command before the end of a section")
self._push_section()
super().do_b(value)
def do_bpp(self, value: str) -> None:
if self.sections or self.frames:
if self.sections or self.current_frames:
raise ValueError("jubeatools does not handle changes of #bpp halfway")
else:
self._do_bpp(value)
@ -100,8 +113,20 @@ class Memo1Parser(JubeatAnalyserParser):
if len(self.current_chart_lines) == 4:
self._push_frame()
def _frames_duration(self) -> Decimal:
return sum((frame.duration for frame in self.frames), start=Decimal(0))
def _frames_duration(self) -> BeatsTime:
return sum(
(frame.duration for frame in self.current_frames), start=BeatsTime(0)
)
def _current_beat(self) -> BeatsTime:
# If we've already seen enough beats, we need to circumvent the wrong
# duration computation
if self._frames_duration() >= self.beats_per_section:
frames_duration = self.beats_per_section
else:
frames_duration = self._frames_duration()
return self.section_starting_beat + frames_duration
def _push_frame(self) -> None:
position_part = [
@ -121,18 +146,24 @@ class Memo1Parser(JubeatAnalyserParser):
# then the current frame starts a new section
self._push_section()
self.frames.append(frame)
self.current_frames.append(frame)
self.current_chart_lines = []
def _push_section(self) -> None:
"""Take all currently stacked frames and push them to a new section,
Move time forward by the number of beats per section"""
if not self.current_frames:
raise RuntimeError(
"Tried pushing a new section but no frames are currently stacked"
)
self.sections.append(
Memo1LoadedSection(
frames=deepcopy(self.frames),
frames=deepcopy(self.current_frames),
length=self.beats_per_section,
tempo=self.current_tempo,
)
)
self.frames = []
self.current_frames = []
self.section_starting_beat += self.beats_per_section
def finish_last_few_notes(self) -> None:
@ -170,23 +201,23 @@ class Memo1Parser(JubeatAnalyserParser):
def _iter_frames(
self,
) -> Iterator[
Tuple[Mapping[str, BeatsTime], Memo1Frame, Decimal, Memo1LoadedSection]
Tuple[Mapping[str, BeatsTime], Memo1Frame, BeatsTime, Memo1LoadedSection]
]:
"""iterate over tuples of
currently_defined_symbols, frame, section_starting_beat, section"""
local_symbols = {}
section_starting_beat = Decimal(0)
section_starting_beat = BeatsTime(0)
for section in self.sections:
frame_starting_beat = Decimal(0)
frame_starting_beat = BeatsTime(0)
for i, frame in enumerate(section.frames):
if frame.timing_part:
frame_starting_beat = sum(
(f.duration for f in section.frames[:i]), start=Decimal(0)
(f.duration for f in section.frames[:i]), start=BeatsTime(0)
)
local_symbols = {
symbol: BeatsTime(symbol_index, len(bar))
+ bar_index
+ decimal_to_beats(frame_starting_beat)
+ frame_starting_beat
for bar_index, bar in enumerate(frame.timing_part)
for symbol_index, symbol in enumerate(bar)
if symbol not in EMPTY_BEAT_SYMBOLS
@ -226,7 +257,7 @@ class Memo1Parser(JubeatAnalyserParser):
continue
should_skip.add(pos)
note_time = decimal_to_beats(section_starting_beat) + symbol_time
note_time = section_starting_beat + symbol_time
yield unfinished_long.ends_at(note_time)
unfinished_longs = {
@ -247,7 +278,7 @@ class Memo1Parser(JubeatAnalyserParser):
should_skip.add(note_pos)
symbol = frame.position_part[note_pos.y][note_pos.x]
symbol_time = currently_defined_symbols[symbol]
note_time = decimal_to_beats(section_starting_beat) + symbol_time
note_time = section_starting_beat + symbol_time
unfinished_longs[note_pos] = UnfinishedLongNote(
time=note_time, position=note_pos, tail_tip=arrow_pos
)
@ -262,7 +293,7 @@ class Memo1Parser(JubeatAnalyserParser):
symbol_time = currently_defined_symbols[symbol]
except KeyError:
continue
note_time = decimal_to_beats(section_starting_beat) + symbol_time
note_time = section_starting_beat + symbol_time
yield TapNote(note_time, position)
def _iter_notes_without_longs(self) -> Iterator[TapNote]:
@ -279,7 +310,7 @@ class Memo1Parser(JubeatAnalyserParser):
symbol_time = currently_defined_symbols[symbol]
except KeyError:
continue
note_time = decimal_to_beats(section_starting_beat) + symbol_time
note_time = section_starting_beat + symbol_time
position = NotePosition(x, y)
yield TapNote(note_time, position)

View File

@ -292,8 +292,8 @@ def _dump_memo2_chart(
# Timing events
sections[0].events.append(StopEvent(BeatsTime(0), timing.beat_zero_offset))
for event in timing_events:
section_index = event.time // 4
sections[section_index].events.append(event)
section_beat = event.time - (event.time % 4)
sections[section_beat].events.append(event)
# Fill sections with notes
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
@ -307,15 +307,15 @@ def _dump_memo2_chart(
file.write(f"// https://github.com/Stepland/jubeatools\n\n")
# Header
file.write(dump_command("lev", int(chart.level)) + "\n")
file.write(dump_command("lev", Decimal(chart.level)) + "\n")
file.write(dump_command("dif", DIFFICULTIES.get(difficulty, 1)) + "\n")
if metadata.audio:
if metadata.audio is not None:
file.write(dump_command("m", metadata.audio) + "\n")
if metadata.title:
if metadata.title is not None:
file.write(dump_command("title", metadata.title) + "\n")
if metadata.artist:
if metadata.artist is not None:
file.write(dump_command("artist", metadata.artist) + "\n")
if metadata.cover:
if metadata.cover is not None:
file.write(dump_command("jacket", metadata.cover) + "\n")
if metadata.preview is not None:
file.write(dump_command("prevpos", int(metadata.preview.start * 1000)) + "\n")

View File

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

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

View File

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

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

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 typing import List, Union
from hypothesis import given
from hypothesis import example, given
from hypothesis import strategies as st
from jubeatools.formats import Format
from jubeatools.formats.jubeat_analyser.memo2.dump import _dump_memo2_chart
from jubeatools.formats.jubeat_analyser.memo2.load import Memo2Parser
from jubeatools.song import (
BeatsTime,
BPMEvent,
@ -12,18 +16,19 @@ from jubeatools.song import (
Metadata,
NotePosition,
SecondsTime,
Song,
TapNote,
Timing,
)
from jubeatools.testutils.strategies import NoteOption
from jubeatools.testutils.strategies import notes as notes_strat
from ..memo2.dump import _dump_memo2_chart
from ..memo2.load import Memo2Parser
from ..test_utils import load_and_dump_then_check, memo_compatible_song
from . import example1, example2, example3
@given(notes_strat(NoteOption.LONGS))
def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None:
def test_that_notes_roundtrip(notes: List[Union[TapNote, LongNote]]) -> None:
timing = Timing(
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
)
@ -41,3 +46,11 @@ def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None:
parser.finish_last_few_notes()
actual = set(parser.notes())
assert notes == actual
@given(memo_compatible_song(), st.booleans())
@example(*example1.data)
@example(*example2.data)
@example(*example3.data)
def test_that_full_chart_roundtrips(song: Song, circle_free: bool) -> None:
load_and_dump_then_check(Format.MEMO_2, song, circle_free)

View File

@ -4,7 +4,7 @@ import pytest
from jubeatools.song import BeatsTime, LongNote, NotePosition, TapNote
from ..mono_column.load import MonoColumnParser
from jubeatools.formats.jubeat_analyser.mono_column.load import MonoColumnParser
def compare_chart_notes(

View File

@ -14,17 +14,22 @@ from jubeatools.song import (
SecondsTime,
TapNote,
Timing,
Song
)
from jubeatools.testutils.strategies import NoteOption, long_note
from jubeatools.testutils.strategies import notes as notes_strat
from jubeatools.testutils.strategies import tap_note
from ..mono_column.dump import _dump_mono_column_chart
from ..mono_column.load import MonoColumnParser
from jubeatools.formats import Format
from jubeatools.formats.jubeat_analyser.mono_column.dump import _dump_mono_column_chart
from jubeatools.formats.jubeat_analyser.mono_column.load import MonoColumnParser
from ..test_utils import load_and_dump_then_check, memo_compatible_song
@given(st.sets(tap_note(), min_size=1, max_size=100))
def test_tap_notes(notes: Set[TapNote]) -> None:
def test_that_a_set_of_tap_notes_roundtrip(notes: Set[TapNote]) -> None:
timing = Timing(
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
)
@ -44,7 +49,7 @@ def test_tap_notes(notes: Set[TapNote]) -> None:
@given(long_note())
def test_single_long_note(note: LongNote) -> None:
def test_that_a_single_long_note_roundtrips(note: LongNote) -> None:
timing = Timing(
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
)
@ -60,7 +65,7 @@ def test_single_long_note(note: LongNote) -> None:
@given(notes_strat(NoteOption.LONGS))
def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None:
def test_that_many_notes_roundtrip(notes: List[Union[TapNote, LongNote]]) -> None:
timing = Timing(
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
)
@ -77,3 +82,8 @@ def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None:
parser.load_line(line)
actual = set(parser.notes())
assert notes == actual
@given(memo_compatible_song(), st.booleans())
def test_that_full_chart_roundtrips(song: Song, circle_free: bool) -> None:
load_and_dump_then_check(Format.MONO_COLUMN, song, circle_free)

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 enum import Enum, Flag, auto
from itertools import product
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, TypeVar, Union
import hypothesis.strategies as st
@ -184,7 +185,14 @@ def get_bpm_change_time(b: BPMEvent) -> BeatsTime:
@st.composite
def chart(draw: DrawFunc, timing_strat: Any, notes_strat: Any) -> Chart:
level = draw(st.integers(min_value=0))
level = Decimal(
draw(
st.one_of(
st.integers(min_value=0),
st.decimals(min_value=0, max_value=10.9, places=1),
)
)
)
timing = draw(timing_strat)
notes = draw(notes_strat)
return Chart(
@ -206,13 +214,18 @@ def preview(draw: DrawFunc) -> Preview:
@st.composite
def metadata(draw: DrawFunc) -> Metadata:
def metadata(
draw: DrawFunc,
text_strat: st.SearchStrategy[str] = st.text(),
path_start: st.SearchStrategy[str] = st.text(),
) -> Metadata:
return Metadata(
title=draw(st.text()),
artist=draw(st.text()),
audio=draw(st.text()),
cover=draw(st.text()),
title=draw(text_strat),
artist=draw(text_strat),
audio=Path(draw(path_start)),
cover=Path(draw(path_start)),
preview=draw(st.one_of(st.none(), preview())),
preview_file=draw(path_start),
)