diff --git a/jubeatools/formats/adapters.py b/jubeatools/formats/adapters.py new file mode 100644 index 0000000..f6de369 --- /dev/null +++ b/jubeatools/formats/adapters.py @@ -0,0 +1,78 @@ +"""Things that make it easier to integrate formats with different opinions +on song folder structure""" + +from itertools import count +from typing import TypedDict, Iterator, Any, Dict, AbstractSet +from pathlib import Path + +from jubeatools.song import Song +from jubeatools.formats.typing import ChartFileDumper, Dumper +from jubeatools.formats.filetypes import ChartFile +from jubeatools.formats.dump_tools import DIFFICULTY_INDEX, DIFFICULTY_NUMBER + + +def make_dumper_from_chart_file_dumper( + internal_dumper: ChartFileDumper, + file_name_template: Path, +) -> Dumper: + """Adapt a ChartFileDumper to the Dumper protocol, The resulting function + uses the file name template if it recieves an existing directory as an + output path""" + + def dumper(song: Song, path: Path, **kwargs: Any) -> Dict[Path, bytes]: + res: Dict[Path, bytes] = {} + if path.is_dir(): + file_path = file_name_template + else: + file_path = path + + name_format = f"{file_path.stem}{{dedup}}{file_path.suffix}" + files = internal_dumper(song, **kwargs) + for chartfile in files: + filepath = choose_file_path(chartfile, name_format, path.parent, res.keys()) + res[filepath] = chartfile.contents + + return res + + return dumper + + +def choose_file_path( + chart_file: ChartFile, + name_format: str, + parent: Path, + already_chosen: AbstractSet[Path], +) -> Path: + all_paths = iter_possible_paths(chart_file, name_format, parent) + not_on_filesystem = filter(lambda p: not p.exists(), all_paths) + not_already_chosen = filter(lambda p: p not in already_chosen, not_on_filesystem) + return next(not_already_chosen) + + +def iter_possible_paths( + chart_file: ChartFile, name_format: str, parent: Path +) -> Iterator[Path]: + for dedup_index in count(start=0): + params = extract_format_params(chart_file, dedup_index) + filename = name_format.format(**params).strip() + yield parent / filename + + +class FormatParameters(TypedDict, total=False): + title: str + difficulty: str + # 0-based + difficulty_index: int + # 1-based + difficulty_number: int + dedup: str + + +def extract_format_params(chartfile: ChartFile, dedup_index: int) -> FormatParameters: + return FormatParameters( + title=chartfile.song.metadata.title or "", + difficulty=chartfile.difficulty, + difficulty_index=DIFFICULTY_INDEX.get(chartfile.difficulty, 3), + difficulty_number=DIFFICULTY_NUMBER.get(chartfile.difficulty, 4), + dedup="" if dedup_index else f"-{dedup_index}", + ) diff --git a/jubeatools/formats/dump_tools.py b/jubeatools/formats/dump_tools.py new file mode 100644 index 0000000..c19d7ad --- /dev/null +++ b/jubeatools/formats/dump_tools.py @@ -0,0 +1,15 @@ +from jubeatools.song import Difficulty +from typing import Dict + + +DIFFICULTY_NUMBER: Dict[str, int] = { + Difficulty.BASIC: 1, + Difficulty.ADVANCED: 2, + Difficulty.EXTREME: 3, +} + +DIFFICULTY_INDEX: Dict[str, int] = { + Difficulty.BASIC: 0, + Difficulty.ADVANCED: 1, + Difficulty.EXTREME: 2, +} diff --git a/jubeatools/formats/eve/__init__.py b/jubeatools/formats/eve/__init__.py index a05dcd9..a37ab86 100644 --- a/jubeatools/formats/eve/__init__.py +++ b/jubeatools/formats/eve/__init__.py @@ -1,3 +1,5 @@ """ .eve is the file format used in arcade releases of jubeat """ + +from .eve import load_eve, dump_eve diff --git a/jubeatools/formats/eve/eve.py b/jubeatools/formats/eve/eve.py index e69de29..49c4e88 100644 --- a/jubeatools/formats/eve/eve.py +++ b/jubeatools/formats/eve/eve.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from enum import Enum +from fractions import Fraction +from typing import List, Union +from io import StringIO +from dataclasses import dataclass +from functools import singledispatch +import ctypes +import warnings +from typing import Dict, Tuple +from pathlib import Path + +from more_itertools import numeric_range + +from jubeatools import song +from jubeatools.formats.filetypes import ChartFile +from jubeatools.formats.adapters import make_dumper_from_chart_file_dumper + +from .timemap import TimeMap + +AnyNote = Union[song.TapNote, song.LongNote] + + +class Command(Enum): + END = 1 + MEASURE = 2 + HAKU = 3 + PLAY = 4 + LONG = 5 + TEMPO = 6 + + +@dataclass(order=True) +class Event: + """Represents a line in an .eve file""" + + time: int + command: Command + value: int + + def dump(self) -> str: + return f"{self.time:>8},{self.command.name:<8},{self.value:>8}" + + +DIRECTION_TO_VALUE = { + song.Direction.DOWN: 0, + song.Direction.UP: 1, + song.Direction.RIGHT: 2, + song.Direction.LEFT: 3, +} + +VALUE_TO_DIRECTION = {v: k for k, v in DIRECTION_TO_VALUE.items()} + + +def _dump_eve(song: song.Song, **kwargs: dict) -> List[ChartFile]: + res = [] + for dif, chart, timing in song.iter_charts_with_timing(): + chart_text = dump_chart(chart.notes, timing) + chart_bytes = chart_text.encode('ascii') + res.append(ChartFile(chart_bytes, song, dif, chart)) + + return res + +dump_eve = make_dumper_from_chart_file_dumper( + internal_dumper=_dump_eve, + file_name_template=Path("{difficulty_index}.eve") +) + + +def dump_chart(notes: List[AnyNote], timing: song.Timing) -> str: + time_map = TimeMap.from_timing(timing) + note_events = make_note_events(notes, time_map) + timing_events = make_timing_events(notes, timing, time_map) + sorted_events = sorted(note_events + timing_events) + return "\n".join(e.dump() for e in sorted_events) + + +def make_note_events(notes: List[AnyNote], time_map: TimeMap) -> List[Event]: + return [make_note_event(note, time_map) for note in notes] + + +@singledispatch +def make_note_event(note: AnyNote, time_map: TimeMap) -> Event: + raise NotImplementedError(f"Note of unknown type : {note}") + + +@make_note_event.register +def make_tap_note_event(note: song.TapNote, time_map: TimeMap) -> Event: + ticks = ticks_at_beat(note.time, time_map) + value = note.position.index + return Event(time=ticks, command=Command.PLAY, value=value) + + +@make_note_event.register +def make_long_note_event(note: song.LongNote, time_map: TimeMap) -> Event: + if not note.has_straight_tail(): + raise ValueError("Diagonal tails cannot be represented in eve format") + + duration = duration_in_ticks(note, time_map) + direction = DIRECTION_TO_VALUE[note.tail_direction()] + length = len(list(note.positions_covered())) - 1 + if not (1 <= length <= 3): + raise ValueError( + f"Given note has a length of {length}, which is not representable " + "in the eve format" + ) + position_index = note.position.index + long_note_value = duration << 8 + length << 6 + direction << 4 + position_index + ticks = ticks_at_beat(note.time, time_map) + return Event(time=ticks, command=Command.LONG, value=long_note_value) + + +def make_timing_events( + notes: List[AnyNote], timing: song.Timing, time_map: TimeMap +) -> List[Event]: + bpm_events = [make_bpm_event(e, time_map) for e in timing.events] + end_beat = choose_end_beat(notes) + end_event = make_end_event(end_beat, time_map) + measure_events = make_measure_events(end_beat, time_map) + beat_events = make_beat_events(end_beat, time_map) + return bpm_events + measure_events + beat_events + [end_event] + + +def make_bpm_event(bpm_change: song.BPMEvent, time_map: TimeMap) -> Event: + ticks = ticks_at_beat(bpm_change.time, time_map) + bpm_value = round(60 * 10 ** 6 / Fraction(bpm_change.BPM)) + return Event(time=ticks, command=Command.TEMPO, value=bpm_value) + + +def choose_end_beat(notes: List[AnyNote]) -> song.BeatsTime: + """Leave 2 empty measures (4 beats) after the last event""" + last_note_beat = compute_last_note_beat(notes) + measure = last_note_beat - (last_note_beat % 4) + return measure + song.BeatsTime(2 * 4) + + +def compute_last_note_beat(notes: List[AnyNote]) -> song.BeatsTime: + """Returns the last beat at which a note event happens, either a tap note, + the start of a long note or the end of a long note. + + If we don't take long notes ends into account we might end up with a long + note end happening after the END tag which will cause jubeat to freeze when + trying to render the note density graph""" + note_times = set(n.time for n in notes) + long_note_ends = set( + n.time + n.duration for n in notes if isinstance(n, song.LongNote) + ) + all_note_times = note_times | long_note_ends + return max(all_note_times) + + +def make_end_event(end_beat: song.BeatsTime, time_map: TimeMap) -> Event: + ticks = ticks_at_beat(end_beat, time_map) + return Event(time=ticks, command=Command.END, value=0) + + +def make_measure_events(end_beat: song.BeatsTime, time_map: TimeMap) -> List[Event]: + start = song.BeatsTime(0) + stop = end_beat + song.BeatsTime(1) + step = song.BeatsTime(4) + beats = numeric_range(start, stop, step) + return [make_measure_event(beat, time_map) for beat in beats] + + +def make_measure_event(beat: song.BeatsTime, time_map: TimeMap) -> Event: + ticks = ticks_at_beat(beat, time_map) + return Event(time=ticks, command=Command.MEASURE, value=0) + + +def make_beat_events(end_beat: song.BeatsTime, time_map: TimeMap) -> List[Event]: + start = song.BeatsTime(0) + stop = end_beat + song.BeatsTime(1, 2) + step = song.BeatsTime(1) + beats = numeric_range(start, stop, step) + return [make_beat_event(beat, time_map) for beat in beats] + + +def make_beat_event(beat: song.BeatsTime, time_map: TimeMap) -> Event: + ticks = ticks_at_beat(beat, time_map) + return Event(time=ticks, command=Command.HAKU, value=0) + +def load_eve(path: Path) -> song.Song: + ... + + + +def ticks_at_beat(time: song.BeatsTime, time_map: TimeMap) -> int: + seconds_time = time_map.fractional_seconds_at(time) + return seconds_to_ticks(seconds_time) + + +def duration_in_ticks(long: song.LongNote, time_map: TimeMap) -> int: + press_time = time_map.fractional_seconds_at(long.time) + release_time = time_map.fractional_seconds_at(long.time + long.duration) + length_in_seconds = release_time - press_time + return seconds_to_ticks(length_in_seconds) + + +def ticks_to_seconds(tick: int) -> Fraction: + """Convert eve ticks (300 Hz) to seconds""" + return Fraction(tick, 300) + + +def seconds_to_ticks(time: Fraction) -> int: + """Convert fractional seconds to eve ticks (300 Hz)""" + return round(time * 300) diff --git a/jubeatools/formats/eve/tests/test_timemap.py b/jubeatools/formats/eve/tests/test_timemap.py index 2afac60..18a5620 100644 --- a/jubeatools/formats/eve/tests/test_timemap.py +++ b/jubeatools/formats/eve/tests/test_timemap.py @@ -14,7 +14,7 @@ def test_that_seconds_at_beat_works_like_the_naive_approach( ) -> None: time_map = TimeMap.from_timing(timing) expected = naive_approach(timing, beat) - actual = time_map._frac_seconds_at(beat) + actual = time_map.fractional_seconds_at(beat) assert actual == expected @@ -34,14 +34,11 @@ def naive_approach(beats: song.Timing, beat: song.BeatsTime) -> Fraction: first_event = sorted_events[0] if first_event.time != song.BeatsTime(0): raise ValueError("First BPM event is not on beat zero") - + if beat > sorted_events[-1].time: events_before = sorted_events else: - last_index = next( - i for i, e in enumerate(sorted_events) - if e.time >= beat - ) + last_index = next(i for i, e in enumerate(sorted_events) if e.time >= beat) events_before = sorted_events[:last_index] total_seconds = Fraction(0) current_beat = beat diff --git a/jubeatools/formats/eve/timemap.py b/jubeatools/formats/eve/timemap.py index a788af2..681a407 100644 --- a/jubeatools/formats/eve/timemap.py +++ b/jubeatools/formats/eve/timemap.py @@ -108,10 +108,10 @@ class TimeMap: ) def seconds_at(self, beat: song.BeatsTime) -> song.SecondsTime: - frac_seconds = self._frac_seconds_at(beat) + frac_seconds = self.fractional_seconds_at(beat) return fraction_to_decimal(frac_seconds) - - def _frac_seconds_at(self, beat): + + def fractional_seconds_at(self, beat: song.BeatsTime) -> Fraction: if beat < 0: raise ValueError("Can't compute seconds at negative beat") diff --git a/jubeatools/formats/files.py b/jubeatools/formats/files.py new file mode 100644 index 0000000..0894942 --- /dev/null +++ b/jubeatools/formats/files.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Dict, List, TypeVar, Protocol, Generic, Optional + + +T = TypeVar("T") + +class FileLoader(Protocol, Generic[T]): + """Function that excepts a path to a file as a parameter and returns its + contents in whatever form suitable for the current format. Returns None in + case of error""" + def __call__(self, path: Path) -> Optional[T]: + ... + +class FolderLoader(Protocol, Generic[T]): + """Function that expects a folder or a file path as a parameter. Loads + either all valid files in the folder or just the given file depending on + the argument""" + def __call__(self, path: Path) -> Dict[Path, T]: + ... + +def make_folder_loader( + glob_pattern: str, + file_loader: FileLoader +) -> FolderLoader: + + def folder_loader(path: Path) -> Dict[Path, T]: + files: Dict[Path, T] = {} + if path.is_dir(): + paths = path.glob(glob_pattern) + else: + paths = [path] + + for p in paths: + value = file_loader(p) + if value is not None: + files[p] = value + + return files + + return folder_loader diff --git a/jubeatools/formats/filetypes.py b/jubeatools/formats/filetypes.py index b7defd2..87eb76a 100644 --- a/jubeatools/formats/filetypes.py +++ b/jubeatools/formats/filetypes.py @@ -6,7 +6,7 @@ from jubeatools.song import Chart, Song @dataclass class JubeatFile: - contents: StringIO + contents: bytes @dataclass diff --git a/jubeatools/formats/formats.py b/jubeatools/formats/formats.py index a5253b3..d6197fb 100644 --- a/jubeatools/formats/formats.py +++ b/jubeatools/formats/formats.py @@ -1,6 +1,7 @@ from typing import Dict from .enum import Format +from .eve import dump_eve, load_eve from .jubeat_analyser import ( dump_memo, dump_memo1, @@ -22,6 +23,7 @@ from .memon import ( from .typing import Dumper, Loader LOADERS: Dict[Format, Loader] = { + Format.EVE: load_eve, Format.MEMON_LEGACY: load_memon_legacy, Format.MEMON_0_1_0: load_memon_0_1_0, Format.MEMON_0_2_0: load_memon_0_2_0, @@ -32,6 +34,7 @@ LOADERS: Dict[Format, Loader] = { } DUMPERS: Dict[Format, Dumper] = { + Format.EVE: dump_eve, Format.MEMON_LEGACY: dump_memon_legacy, Format.MEMON_0_1_0: dump_memon_0_1_0, Format.MEMON_0_2_0: dump_memon_0_2_0, diff --git a/jubeatools/formats/jubeat_analyser/dump_tools.py b/jubeatools/formats/jubeat_analyser/dump_tools.py index de15712..8bb00dd 100644 --- a/jubeatools/formats/jubeat_analyser/dump_tools.py +++ b/jubeatools/formats/jubeat_analyser/dump_tools.py @@ -22,8 +22,10 @@ from typing import ( from more_itertools import windowed from sortedcontainers import SortedDict, SortedKeyList +from jubeatools.formats.adapters import make_dumper_from_chart_file_dumper from jubeatools.formats.filetypes import ChartFile from jubeatools.formats.typing import Dumper +from jubeatools.formats.dump_tools import DIFFICULTY_NUMBER from jubeatools.song import ( BeatsTime, Chart, @@ -33,11 +35,14 @@ from jubeatools.song import ( Song, TapNote, Timing, + Direction, + Difficulty, ) from jubeatools.utils import fraction_to_decimal from .command import dump_command from .symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS +from .typing import JubeatAnalyserChartDumper COMMAND_ORDER = [ "b", @@ -66,25 +71,24 @@ BEATS_TIME_TO_CIRCLE_FREE = { NOTE_TO_CIRCLE_FREE_SYMBOL = dict(zip(NOTE_SYMBOLS, CIRCLE_FREE_SYMBOLS)) DIRECTION_TO_ARROW = { - NotePosition(-1, 0): ">", # U+FF1E : FULLWIDTH GREATER-THAN SIGN - NotePosition(1, 0): "<", # U+FF1C : FULLWIDTH LESS-THAN SIGN - NotePosition(0, -1): "∨", # U+2228 : LOGICAL OR - NotePosition(0, 1): "∧", # U+2227 : LOGICAL AND + Direction.RIGHT: ">", # U+FF1E : FULLWIDTH GREATER-THAN SIGN + Direction.LEFT: "<", # U+FF1C : FULLWIDTH LESS-THAN SIGN + Direction.DOWN: "∨", # U+2228 : LOGICAL OR + Direction.UP: "∧", # U+2227 : LOGICAL AND } # do NOT use the regular vertical bar, it will clash with the timing portion DIRECTION_TO_LINE = { - NotePosition(-1, 0): "―", # U+2015 : HORIZONTAL BAR - NotePosition(1, 0): "―", - NotePosition(0, -1): "|", # U+FF5C : FULLWIDTH VERTICAL LINE - NotePosition(0, 1): "|", + Direction.RIGHT: "―", # U+2015 : HORIZONTAL BAR + Direction.LEFT: "―", + Direction.UP: "|", # U+FF5C : FULLWIDTH VERTICAL LINE + Direction.DOWN: "|", } -DIFFICULTIES = {"BSC": 1, "ADV": 2, "EXT": 3} - # I put a FUCKTON of extra characters just in case some insane chart uses -# loads of unusual beat divisions. The Vs are left out on purpose since they -# would be mistaken for long note arrows +# loads of unusual beat divisions. +# /!\ The Vs are left out on purpose since they would be mistaken for +# long note arrows DEFAULT_EXTRA_SYMBOLS = ( "ABCDEFGHIJKLMNOPQRSTUWXYZ" "abcdefghijklmnopqrstuwxyz" @@ -206,7 +210,7 @@ def create_sections_from_chart( header = sections[BeatsTime(0)].commands header["o"] = int(timing.beat_zero_offset * 1000) header["lev"] = Decimal(chart.level) - header["dif"] = DIFFICULTIES.get(difficulty, 3) + header["dif"] = DIFFICULTY_NUMBER.get(difficulty, 3) if metadata.audio is not None: header["m"] = metadata.audio if metadata.title is not None: @@ -259,43 +263,28 @@ def create_sections_from_chart( return sections -def jubeat_analyser_file_dumper( - internal_dumper: Callable[[Song, bool], List[ChartFile]] -) -> Dumper: - """Factory function to create a jubeat analyser file dumper from the internal dumper""" +def make_full_dumper_from_jubeat_analyser_chart_dumper(chart_dumper: JubeatAnalyserChartDumper) -> Dumper: + """Factory function to create a fully fledged song dumper from + the internal chart dumper of jubeat analyser formats""" - def dumper( - song: Song, path: Path, *, circle_free: bool = False, **kwargs: Any - ) -> Dict[Path, bytes]: - files = internal_dumper(song, circle_free) - res = {} - if path.is_dir(): - title = song.metadata.title or "out" - name_format = title + " {difficulty}{dedup_index}.txt" - else: - name_format = "{base}{dedup_index}{ext}" - - for chartfile in files: - i = 0 - filepath = name_format.format( - base=path.parent / path.stem, - difficulty=DIFFICULTIES.get(chartfile.difficulty, chartfile.difficulty), - dedup_index="" if i == 0 else f"-{i}", - ext=path.suffix, + def song_dumper( + song: Song, *, circle_free: bool = False, **kwargs: Any + ) -> List[ChartFile]: + files: List[ChartFile] = [] + for difficulty, chart, timing in song.iter_charts_with_timing(): + chart_file = chart_dumper( + difficulty, + chart, + song.metadata, + timing, + circle_free, ) - while filepath in res: - i += 1 - filepath = name_format.format( - base=path.parent / path.stem, - difficulty=DIFFICULTIES.get( - chartfile.difficulty, chartfile.difficulty - ), - dedup_index="" if i == 0 else f"-{i}", - ext=path.suffix, - ) + file_bytes = chart_file.getvalue().encode("shift-jis-2004") + files.append(ChartFile(file_bytes, song, difficulty, chart)) - res[Path(filepath)] = chartfile.contents.getvalue().encode("shift-jis-2004") + return files - return res - - return dumper + return make_dumper_from_chart_file_dumper( + internal_dumper=song_dumper, + file_name_template=Path("{title} {difficulty_number}.txt"), + ) diff --git a/jubeatools/formats/jubeat_analyser/files.py b/jubeatools/formats/jubeat_analyser/files.py index 0f6fcd4..ba3649b 100644 --- a/jubeatools/formats/jubeat_analyser/files.py +++ b/jubeatools/formats/jubeat_analyser/files.py @@ -1,22 +1,20 @@ from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Optional +from jubeatools.formats.files import make_folder_loader -def load_files(path: Path) -> Dict[Path, List[str]]: - # The vast majority of memo files you will encounter will be propely - # decoded using shift-jis-2004. Get ready for endless fun with the small - # portion of files that won't - files: Dict[Path, List[str]] = {} - if path.is_dir(): - for f in path.glob("*.txt"): - _load_file(f, files) - elif path.is_file(): - _load_file(path, files) - return files - - -def _load_file(path: Path, files: Dict[Path, List[str]]) -> None: +def read_jubeat_analyser_file(path: Path) -> Optional[List[str]]: try: - files[path] = path.read_text(encoding="shift-jis-2004").split("\n") + # The vast majority of memo files you will encounter will be propely + # decoded using shift-jis-2004. Get ready for endless fun with the small + # portion of files that won't + lines = path.read_text(encoding="shift-jis-2004").split("\n") except UnicodeDecodeError: - pass + return None + else: + return lines + +load_folder = make_folder_loader( + glob_pattern="*.txt", + file_loader=read_jubeat_analyser_file, +) diff --git a/jubeatools/formats/jubeat_analyser/load_tools.py b/jubeatools/formats/jubeat_analyser/load_tools.py index fe468ce..2ffa6d6 100644 --- a/jubeatools/formats/jubeat_analyser/load_tools.py +++ b/jubeatools/formats/jubeat_analyser/load_tools.py @@ -13,7 +13,7 @@ import constraint from parsimonious import Grammar, NodeVisitor, ParseError from parsimonious.nodes import Node -from jubeatools.song import BeatsTime, BPMEvent, LongNote, NotePosition +from jubeatools.song import BeatsTime, BPMEvent, LongNote, NotePosition, Difficulty from .symbols import ( CIRCLE_FREE_SYMBOLS, @@ -24,7 +24,11 @@ from .symbols import ( NOTE_SYMBOLS, ) -DIFFICULTIES = {1: "BSC", 2: "ADV", 3: "EXT"} +DIFFICULTIES = { + 1: Difficulty.BASIC, + 2: Difficulty.ADVANCED, + 3: Difficulty.EXTREME, +} SYMBOL_TO_BEATS_TIME = {c: BeatsTime("1/4") * i for i, c in enumerate(NOTE_SYMBOLS)} diff --git a/jubeatools/formats/jubeat_analyser/memo/dump.py b/jubeatools/formats/jubeat_analyser/memo/dump.py index 2aef9b7..d6299a4 100644 --- a/jubeatools/formats/jubeat_analyser/memo/dump.py +++ b/jubeatools/formats/jubeat_analyser/memo/dump.py @@ -4,7 +4,7 @@ from fractions import Fraction from io import StringIO from itertools import zip_longest from math import ceil -from typing import Dict, Iterator, List, Union +from typing import Dict, Iterator, List, Union, Any from more_itertools import chunked, collapse, intersperse, mark_ends, windowed @@ -29,7 +29,7 @@ from ..dump_tools import ( JubeatAnalyserDumpedSection, LongNoteEnd, create_sections_from_chart, - jubeat_analyser_file_dumper, + make_full_dumper_from_jubeat_analyser_chart_dumper, ) from ..symbols import NOTE_SYMBOLS @@ -199,7 +199,7 @@ def _raise_if_unfit_for_memo(chart: Chart, timing: Timing, circle_free: bool) -> raise ValueError("First BPM event does not happen on beat zero") if any( - not note.tail_is_straight() + not note.has_straight_tail() for note in chart.notes if isinstance(note, LongNote) ): @@ -218,7 +218,7 @@ def _dump_memo_chart( chart: Chart, metadata: Metadata, timing: Timing, - circle_free: bool, + circle_free: bool = False, ) -> StringIO: _raise_if_unfit_for_memo(chart, timing, circle_free) @@ -258,21 +258,4 @@ def _dump_memo_chart( return file -def _dump_memo_internal(song: Song, circle_free: bool) -> List[ChartFile]: - files: List[ChartFile] = [] - for difficulty, chart in song.charts.items(): - timing = chart.timing or song.global_timing - assert timing is not None - contents = _dump_memo_chart( - difficulty, - chart, - song.metadata, - timing, - circle_free, - ) - files.append(ChartFile(contents, song, difficulty, chart)) - - return files - - -dump_memo = jubeat_analyser_file_dumper(_dump_memo_internal) +dump_memo = make_full_dumper_from_jubeat_analyser_chart_dumper(_dump_memo_chart) diff --git a/jubeatools/formats/jubeat_analyser/memo/load.py b/jubeatools/formats/jubeat_analyser/memo/load.py index f4ebfd1..16163d3 100644 --- a/jubeatools/formats/jubeat_analyser/memo/load.py +++ b/jubeatools/formats/jubeat_analyser/memo/load.py @@ -24,7 +24,7 @@ from jubeatools.song import ( from jubeatools.utils import none_or from ..command import is_command, parse_command -from ..files import load_files +from ..files import load_folder from ..load_tools import ( CIRCLE_FREE_TO_NOTE_SYMBOL, EMPTY_BEAT_SYMBOLS, @@ -348,6 +348,6 @@ def _load_memo_file(lines: List[str]) -> Song: def load_memo(path: Path) -> Song: - files = load_files(path) + files = load_folder(path) charts = [_load_memo_file(lines) for _, lines in files.items()] return reduce(Song.merge, charts) diff --git a/jubeatools/formats/jubeat_analyser/memo1/dump.py b/jubeatools/formats/jubeat_analyser/memo1/dump.py index da96201..ecd0f3a 100644 --- a/jubeatools/formats/jubeat_analyser/memo1/dump.py +++ b/jubeatools/formats/jubeat_analyser/memo1/dump.py @@ -4,7 +4,7 @@ from fractions import Fraction from io import StringIO from itertools import zip_longest from math import ceil -from typing import Dict, Iterator, List, Union +from typing import Dict, Iterator, List, Union, Any from more_itertools import collapse, intersperse, mark_ends, windowed @@ -29,7 +29,7 @@ from ..dump_tools import ( JubeatAnalyserDumpedSection, LongNoteEnd, create_sections_from_chart, - jubeat_analyser_file_dumper, + make_full_dumper_from_jubeat_analyser_chart_dumper, ) from ..symbols import NOTE_SYMBOLS @@ -196,7 +196,7 @@ def _raise_if_unfit_for_memo1( raise ValueError("First BPM event does not happen on beat zero") if any( - not note.tail_is_straight() + not note.has_straight_tail() for note in chart.notes if isinstance(note, LongNote) ): @@ -238,21 +238,4 @@ def _dump_memo1_chart( return file -def _dump_memo1_internal(song: Song, circle_free: bool) -> List[ChartFile]: - files: List[ChartFile] = [] - for difficulty, chart in song.charts.items(): - timing = chart.timing or song.global_timing - assert timing is not None - contents = _dump_memo1_chart( - difficulty, - chart, - song.metadata, - timing, - circle_free, - ) - files.append(ChartFile(contents, song, difficulty, chart)) - - return files - - -dump_memo1 = jubeat_analyser_file_dumper(_dump_memo1_internal) +dump_memo1 = make_full_dumper_from_jubeat_analyser_chart_dumper(_dump_memo1_chart) diff --git a/jubeatools/formats/jubeat_analyser/memo1/load.py b/jubeatools/formats/jubeat_analyser/memo1/load.py index 7f1d511..74093a2 100644 --- a/jubeatools/formats/jubeat_analyser/memo1/load.py +++ b/jubeatools/formats/jubeat_analyser/memo1/load.py @@ -23,7 +23,7 @@ from jubeatools.song import ( from jubeatools.utils import none_or from ..command import is_command, parse_command -from ..files import load_files +from ..files import load_folder from ..load_tools import ( CIRCLE_FREE_TO_NOTE_SYMBOL, EMPTY_BEAT_SYMBOLS, @@ -339,6 +339,6 @@ def _load_memo1_file(lines: List[str]) -> Song: def load_memo1(path: Path) -> Song: - files = load_files(path) + files = load_folder(path) charts = [_load_memo1_file(lines) for _, lines in files.items()] return reduce(Song.merge, charts) diff --git a/jubeatools/formats/jubeat_analyser/memo2/dump.py b/jubeatools/formats/jubeat_analyser/memo2/dump.py index 22b8c22..b4b3de1 100644 --- a/jubeatools/formats/jubeat_analyser/memo2/dump.py +++ b/jubeatools/formats/jubeat_analyser/memo2/dump.py @@ -4,7 +4,7 @@ from decimal import Decimal from fractions import Fraction from io import StringIO from itertools import chain, zip_longest -from typing import Dict, Iterator, List, Optional, Union +from typing import Dict, Iterator, List, Optional, Union, Any from more_itertools import collapse, intersperse, mark_ends, windowed from sortedcontainers import SortedKeyList @@ -27,13 +27,13 @@ from jubeatools.version import __version__ from ..command import dump_command from ..dump_tools import ( - DIFFICULTIES, + DIFFICULTY_NUMBER, DIRECTION_TO_ARROW, DIRECTION_TO_LINE, NOTE_TO_CIRCLE_FREE_SYMBOL, LongNoteEnd, SortedDefaultDict, - jubeat_analyser_file_dumper, + make_full_dumper_from_jubeat_analyser_chart_dumper, ) from ..symbols import NOTE_SYMBOLS @@ -241,7 +241,7 @@ def _raise_if_unfit_for_memo2( raise ValueError("First BPM event does not happen on beat zero") if any( - not note.tail_is_straight() + not note.has_straight_tail() for note in chart.notes if isinstance(note, LongNote) ): @@ -302,7 +302,7 @@ def _dump_memo2_chart( # Header file.write(dump_command("lev", Decimal(chart.level)) + "\n") - file.write(dump_command("dif", DIFFICULTIES.get(difficulty, 1)) + "\n") + file.write(dump_command("dif", DIFFICULTY_NUMBER.get(difficulty, 1)) + "\n") if metadata.audio is not None: file.write(dump_command("m", metadata.audio) + "\n") if metadata.title is not None: @@ -332,21 +332,4 @@ def _dump_memo2_chart( return file -def _dump_memo2_internal(song: Song, circle_free: bool = False) -> List[ChartFile]: - files: List[ChartFile] = [] - for difficulty, chart in song.charts.items(): - timing = chart.timing or song.global_timing - assert timing is not None - contents = _dump_memo2_chart( - difficulty, - chart, - song.metadata, - timing, - circle_free, - ) - files.append(ChartFile(contents, song, difficulty, chart)) - - return files - - -dump_memo2 = jubeat_analyser_file_dumper(_dump_memo2_internal) +dump_memo2 = make_full_dumper_from_jubeat_analyser_chart_dumper(_dump_memo2_chart) diff --git a/jubeatools/formats/jubeat_analyser/memo2/load.py b/jubeatools/formats/jubeat_analyser/memo2/load.py index df7f24c..b93e8c7 100644 --- a/jubeatools/formats/jubeat_analyser/memo2/load.py +++ b/jubeatools/formats/jubeat_analyser/memo2/load.py @@ -24,7 +24,7 @@ from jubeatools.song import ( from jubeatools.utils import none_or from ..command import is_command, parse_command -from ..files import load_files +from ..files import load_folder from ..load_tools import ( CIRCLE_FREE_TO_NOTE_SYMBOL, EMPTY_BEAT_SYMBOLS, @@ -458,6 +458,6 @@ def _load_memo2_file(lines: List[str]) -> Song: def load_memo2(path: Path) -> Song: - files = load_files(path) + files = load_folder(path) charts = [_load_memo2_file(lines) for _, lines in files.items()] return reduce(Song.merge, charts) diff --git a/jubeatools/formats/jubeat_analyser/mono_column/dump.py b/jubeatools/formats/jubeat_analyser/mono_column/dump.py index 8d43058..56c5eef 100644 --- a/jubeatools/formats/jubeat_analyser/mono_column/dump.py +++ b/jubeatools/formats/jubeat_analyser/mono_column/dump.py @@ -1,6 +1,6 @@ from copy import deepcopy from io import StringIO -from typing import Dict, Iterator, List +from typing import Dict, Iterator, List, Any from more_itertools import collapse, intersperse, mark_ends @@ -26,7 +26,7 @@ from ..dump_tools import ( JubeatAnalyserDumpedSection, LongNoteEnd, create_sections_from_chart, - jubeat_analyser_file_dumper, + make_full_dumper_from_jubeat_analyser_chart_dumper, ) @@ -100,7 +100,7 @@ def _raise_if_unfit_for_mono_column(chart: Chart, timing: Timing) -> None: raise ValueError("First BPM event does not happen on beat zero") if any( - not note.tail_is_straight() + not note.has_straight_tail() for note in chart.notes if isinstance(note, LongNote) ): @@ -151,22 +151,4 @@ def _dump_mono_column_chart( return file - -def _dump_mono_column_internal(song: Song, circle_free: bool) -> List[ChartFile]: - files: List[ChartFile] = [] - for difficulty, chart in song.charts.items(): - timing = chart.timing or song.global_timing - assert timing is not None - contents = _dump_mono_column_chart( - difficulty, - chart, - song.metadata, - timing, - circle_free, - ) - files.append(ChartFile(contents, song, difficulty, chart)) - - return files - - -dump_mono_column = jubeat_analyser_file_dumper(_dump_mono_column_internal) +dump_mono_column = make_full_dumper_from_jubeat_analyser_chart_dumper(_dump_mono_column_chart) diff --git a/jubeatools/formats/jubeat_analyser/mono_column/load.py b/jubeatools/formats/jubeat_analyser/mono_column/load.py index f2d987a..1853b7b 100644 --- a/jubeatools/formats/jubeat_analyser/mono_column/load.py +++ b/jubeatools/formats/jubeat_analyser/mono_column/load.py @@ -24,7 +24,7 @@ from jubeatools.song import ( from jubeatools.utils import none_or from ..command import is_command, parse_command -from ..files import load_files +from ..files import load_folder from ..load_tools import ( CIRCLE_FREE_TO_BEATS_TIME, JubeatAnalyserParser, @@ -242,9 +242,9 @@ class MonoColumnParser(JubeatAnalyserParser): def load_mono_column(path: Path) -> Song: - files = load_files(path) + files = load_folder(path) charts = [_load_mono_column_file(lines) for _, lines in files.items()] - return reduce(lambda a, b: a.merge(b), charts) + return reduce(Song.merge, charts) def _load_mono_column_file(lines: List[str]) -> Song: diff --git a/jubeatools/formats/jubeat_analyser/tests/memo/example2.py b/jubeatools/formats/jubeat_analyser/tests/memo/example2.py index d0dfc30..d08f72a 100644 --- a/jubeatools/formats/jubeat_analyser/tests/memo/example2.py +++ b/jubeatools/formats/jubeat_analyser/tests/memo/example2.py @@ -23,7 +23,7 @@ data = ( notes=[], ) }, - global_timing=None, + common_timing=None, ), False, ) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo/example3.py b/jubeatools/formats/jubeat_analyser/tests/memo/example3.py index bee1e3b..5414b8e 100644 --- a/jubeatools/formats/jubeat_analyser/tests/memo/example3.py +++ b/jubeatools/formats/jubeat_analyser/tests/memo/example3.py @@ -26,7 +26,7 @@ data = ( notes=[], ) }, - global_timing=None, + common_timing=None, ), False, ) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo1/example1.py b/jubeatools/formats/jubeat_analyser/tests/memo1/example1.py index 901e672..d301668 100644 --- a/jubeatools/formats/jubeat_analyser/tests/memo1/example1.py +++ b/jubeatools/formats/jubeat_analyser/tests/memo1/example1.py @@ -30,7 +30,7 @@ data = ( ], ) }, - global_timing=None, + common_timing=None, ), False, ) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo2/example1.py b/jubeatools/formats/jubeat_analyser/tests/memo2/example1.py index b748787..f8c3e8f 100644 --- a/jubeatools/formats/jubeat_analyser/tests/memo2/example1.py +++ b/jubeatools/formats/jubeat_analyser/tests/memo2/example1.py @@ -26,7 +26,7 @@ data = ( notes=[], ) }, - global_timing=None, + common_timing=None, ), False, ) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo2/example2.py b/jubeatools/formats/jubeat_analyser/tests/memo2/example2.py index b256ff5..01631a9 100644 --- a/jubeatools/formats/jubeat_analyser/tests/memo2/example2.py +++ b/jubeatools/formats/jubeat_analyser/tests/memo2/example2.py @@ -26,7 +26,7 @@ data = ( notes=[], ) }, - global_timing=None, + common_timing=None, ), False, ) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo2/example3.py b/jubeatools/formats/jubeat_analyser/tests/memo2/example3.py index 34dcc7d..1c2f433 100644 --- a/jubeatools/formats/jubeat_analyser/tests/memo2/example3.py +++ b/jubeatools/formats/jubeat_analyser/tests/memo2/example3.py @@ -26,7 +26,7 @@ data = ( notes=[], ) }, - global_timing=None, + common_timing=None, ), False, ) diff --git a/jubeatools/formats/jubeat_analyser/tests/test_utils.py b/jubeatools/formats/jubeat_analyser/tests/test_utils.py index 860f37a..d9ebe36 100644 --- a/jubeatools/formats/jubeat_analyser/tests/test_utils.py +++ b/jubeatools/formats/jubeat_analyser/tests/test_utils.py @@ -25,7 +25,7 @@ def memo_compatible_metadata(draw: DrawFunc) -> song.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"])) + diff = draw(st.sampled_from(list(d.value for d in song.Difficulty))) chart = draw( jbst.chart( timing_strat=jbst.timing_info(bpm_changes=True), diff --git a/jubeatools/formats/jubeat_analyser/typing.py b/jubeatools/formats/jubeat_analyser/typing.py new file mode 100644 index 0000000..fe5b320 --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/typing.py @@ -0,0 +1,18 @@ +from typing import Protocol +from io import StringIO + +from jubeatools.song import Chart, Metadata, Timing + + +class JubeatAnalyserChartDumper(Protocol): + """Internal Dumper for jubeat analyser formats""" + + def __call__( + self, + difficulty: str, + chart: Chart, + metadata: Metadata, + timing: Timing, + circle_free: bool = False, + ) -> StringIO: + ... diff --git a/jubeatools/formats/memon/memon.py b/jubeatools/formats/memon/memon.py index 8f3877f..4c9e0a6 100644 --- a/jubeatools/formats/memon/memon.py +++ b/jubeatools/formats/memon/memon.py @@ -157,7 +157,7 @@ def load_memon_legacy(file: Path) -> jbt.Song: audio=Path(memon["metadata"]["audio"]), cover=Path(memon["metadata"]["cover"]), ) - global_timing = jbt.Timing( + common_timing = jbt.Timing( events=[jbt.BPMEvent(time=jbt.BeatsTime(0), BPM=memon["metadata"]["BPM"])], beat_zero_offset=jbt.SecondsTime(-memon["metadata"]["offset"]), ) @@ -174,7 +174,7 @@ def load_memon_legacy(file: Path) -> jbt.Song: ), ) - return jbt.Song(metadata=metadata, charts=charts, global_timing=global_timing) + return jbt.Song(metadata=metadata, charts=charts, common_timing=common_timing) def load_memon_0_1_0(file: Path) -> jbt.Song: @@ -187,7 +187,7 @@ def load_memon_0_1_0(file: Path) -> jbt.Song: audio=Path(memon["metadata"]["audio"]), cover=Path(memon["metadata"]["cover"]), ) - global_timing = jbt.Timing( + common_timing = jbt.Timing( events=[jbt.BPMEvent(time=jbt.BeatsTime(0), BPM=memon["metadata"]["BPM"])], beat_zero_offset=jbt.SecondsTime(-memon["metadata"]["offset"]), ) @@ -204,7 +204,7 @@ def load_memon_0_1_0(file: Path) -> jbt.Song: ), ) - return jbt.Song(metadata=metadata, charts=charts, global_timing=global_timing) + return jbt.Song(metadata=metadata, charts=charts, common_timing=common_timing) def load_memon_0_2_0(file: Path) -> jbt.Song: @@ -224,7 +224,7 @@ def load_memon_0_2_0(file: Path) -> jbt.Song: cover=Path(memon["metadata"]["cover"]), preview=preview, ) - global_timing = jbt.Timing( + common_timing = jbt.Timing( events=[jbt.BPMEvent(time=jbt.BeatsTime(0), BPM=memon["metadata"]["BPM"])], beat_zero_offset=jbt.SecondsTime(-memon["metadata"]["offset"]), ) @@ -241,7 +241,7 @@ def load_memon_0_2_0(file: Path) -> jbt.Song: ), ) - return jbt.Song(metadata=metadata, charts=charts, global_timing=global_timing) + return jbt.Song(metadata=metadata, charts=charts, common_timing=common_timing) def _long_note_tail_value_v0(note: jbt.LongNote) -> int: @@ -256,8 +256,8 @@ def _long_note_tail_value_v0(note: jbt.LongNote) -> int: def _get_timing(song: jbt.Song) -> jbt.Timing: - if song.global_timing is not None: - return song.global_timing + if song.common_timing is not None: + return song.common_timing else: return next( chart.timing for chart in song.charts.values() if chart.timing is not None @@ -269,7 +269,7 @@ def _raise_if_unfit_for_v0(song: jbt.Song, version: str) -> None: """Raises an exception if the Song object is ill-formed or contains information that cannot be represented in a memon v0.x.y file (includes legacy)""" - if song.global_timing is None and all( + if song.common_timing is None and all( chart.timing is None for chart in song.charts.values() ): raise ValueError("The song has no timing information") diff --git a/jubeatools/formats/typing.py b/jubeatools/formats/typing.py index 1fd1e12..5aea68e 100644 --- a/jubeatools/formats/typing.py +++ b/jubeatools/formats/typing.py @@ -1,7 +1,8 @@ from pathlib import Path -from typing import Any, Callable, Dict, Protocol +from typing import Any, Callable, Dict, Protocol, List from jubeatools.song import Song +from jubeatools.formats.filetypes import ChartFile # Dumpers take a Song object and a Path hint and give back a dict that maps @@ -15,6 +16,14 @@ class Dumper(Protocol): ... +class ChartFileDumper(Protocol): + """Generic signature of internal dumper for formats that use one file + per chart""" + + def __call__(self, song: Song, **kwargs: Any) -> List[ChartFile]: + ... + + # Loaders deserialize a Path to a Song object # The Path can be a file or a folder depending on the format Loader = Callable[[Path], Song] diff --git a/jubeatools/song.py b/jubeatools/song.py index 59ae26a..08dd3d5 100644 --- a/jubeatools/song.py +++ b/jubeatools/song.py @@ -14,7 +14,8 @@ from decimal import Decimal from fractions import Fraction from functools import wraps from pathlib import Path -from typing import Any, Callable, Iterator, List, Mapping, Optional, Type, Union +from typing import Any, Callable, Iterator, List, Mapping, Optional, Type, Union, Tuple +from enum import Enum, auto from multidict import MultiDict @@ -50,6 +51,17 @@ def convert_other( @dataclass(frozen=True, order=True) class NotePosition: + """A specific square on the controller. (0, 0) is the top-left button, x + goes right, y goes down. + + x → + 0 1 2 3 + y 0 □ □ □ □ + ↓ 1 □ □ □ □ + 2 □ □ □ □ + 3 □ □ □ □ + """ + x: int y: int @@ -61,7 +73,7 @@ class NotePosition: return self.x + 4 * self.y @classmethod - def from_index(cls: Type["NotePosition"], index: int) -> "NotePosition": + def from_index(cls, index: int) -> NotePosition: if not (0 <= index < 16): raise ValueError(f"Note position index out of range : {index}") @@ -87,34 +99,55 @@ class LongNote: time: BeatsTime position: NotePosition duration: BeatsTime - # tail_tip starting position as absolute position on the - # playfield + # tail tip starting position as absolute position on the playfield tail_tip: NotePosition - def tail_is_straight(self) -> bool: + def has_straight_tail(self) -> bool: return (self.position.x == self.tail_tip.x) or ( self.position.y == self.tail_tip.y ) - def tail_direction(self) -> NotePosition: - if not self.tail_is_straight(): + def tail_direction(self) -> Direction: + """Direction in which the tail moves""" + if not self.has_straight_tail(): raise ValueError("Can't get tail direction when it's not straight") - x, y = astuple(self.tail_tip - self.position) - if x == 0: - y //= abs(y) + + if self.tail_tip.x == self.position.x: + if self.tail_tip.y > self.position.y: + return Direction.UP + else: + return Direction.DOWN else: - x //= abs(x) - return NotePosition(x, y) + if self.tail_tip.x > self.position.x: + return Direction.LEFT + else: + return Direction.RIGHT def positions_covered(self) -> Iterator[NotePosition]: direction = self.tail_direction() + step = TAIL_DIRECTION_TO_NOTE_TO_TAIL_VECTOR[direction] position = self.position yield position while position != self.tail_tip: - position = position + direction + position = position + step yield position +class Direction(Enum): + UP = auto() + DOWN = auto() + LEFT = auto() + RIGHT = auto() + + +TAIL_DIRECTION_TO_NOTE_TO_TAIL_VECTOR = { + Direction.UP: NotePosition(0, 1), + Direction.DOWN: NotePosition(0, -1), + Direction.LEFT: NotePosition(1, 0), + Direction.RIGHT: NotePosition(-1, 0), +} + + @dataclass(frozen=True) class BPMEvent: time: BeatsTime @@ -150,6 +183,12 @@ class Metadata: preview_file: Optional[Path] = None +class Difficulty(str, Enum): + BASIC = "BSC" + ADVANCED = "ADV" + EXTREME = "EXT" + + @dataclass class Song: @@ -158,7 +197,7 @@ class Song: metadata: Metadata charts: Mapping[str, Chart] = field(default_factory=MultiDict) - global_timing: Optional[Timing] = None + common_timing: Optional[Timing] = None def merge(self, other: "Song") -> "Song": if self.metadata != other.metadata: @@ -171,10 +210,19 @@ class Song: charts.extend(self.charts) charts.extend(other.charts) if ( - self.global_timing is not None - and other.global_timing is not None - and self.global_timing != other.global_timing + self.common_timing is not None + and other.common_timing is not None + and self.common_timing != other.common_timing ): raise ValueError("Can't merge songs with differing global timings") - global_timing = self.global_timing or other.global_timing - return Song(self.metadata, charts, global_timing) + common_timing = self.common_timing or other.common_timing + return Song(self.metadata, charts, common_timing) + + def iter_charts_with_timing(self) -> Iterator[Tuple[str, Chart, Timing]]: + for dif, chart in self.charts.items(): + timing = chart.timing or self.common_timing + if timing is None: + raise ValueError( + f"Neither song nor {dif} chart have any timing information" + ) + yield dif, chart, timing diff --git a/jubeatools/testutils/strategies.py b/jubeatools/testutils/strategies.py index 53ebfbc..6d29ac4 100644 --- a/jubeatools/testutils/strategies.py +++ b/jubeatools/testutils/strategies.py @@ -22,6 +22,7 @@ from jubeatools.song import ( Song, TapNote, Timing, + Difficulty, ) from jubeatools.testutils.typing import DrawFunc @@ -251,7 +252,7 @@ def song( timing_strat = timing_info(TimingOption.BPM_CHANGES in timing_options) note_strat = notes(notes_options) - diff_name_strat = st.sampled_from(["BSC", "ADV", "EXT"]) + diff_name_strat = st.sampled_from(list(d.value for d in Difficulty)) if extra_diffs: # only go for ascii in extra diffs # https://en.wikipedia.org/wiki/Basic_Latin_(Unicode_block) @@ -272,12 +273,12 @@ def song( _chart = draw(chart(chart_timing_strat, note_strat)) charts.add(diff_name, _chart) - global_timing_start: st.SearchStrategy[Optional[Timing]] = st.none() + common_timing_start: st.SearchStrategy[Optional[Timing]] = st.none() if TimingOption.GLOBAL in timing_options: - global_timing_start = timing_strat + common_timing_start = timing_strat return Song( metadata=draw(metadata()), charts=charts, - global_timing=draw(global_timing_start), + common_timing=draw(common_timing_start), )