From cb2bbfe370707f741f5b9af4cca676bcfb8ea52a Mon Sep 17 00:00:00 2001 From: Stepland <16676308+Stepland@users.noreply.github.com> Date: Mon, 10 May 2021 00:39:28 +0200 Subject: [PATCH] Split eve between dump and load files --- CHANGELOG.md | 14 +- jubeatools/formats/eve/__init__.py | 3 +- jubeatools/formats/eve/commons.py | 148 +++++++++++++++++++++ jubeatools/formats/eve/{eve.py => dump.py} | 102 ++------------ jubeatools/formats/eve/load.py | 69 ++++++++++ 5 files changed, 238 insertions(+), 98 deletions(-) create mode 100644 jubeatools/formats/eve/commons.py rename jubeatools/formats/eve/{eve.py => dump.py} (61%) create mode 100644 jubeatools/formats/eve/load.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e6abe7c..002d817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,20 @@ # Unreleased +## Added +- [eve] 🎉 Add support for .eve as output ## Fixed - [jubeat-analyser] Prettier rendering of decimal values # v0.2.0 ## Added -- [#mono-column] #circlefree mode accepts non-16ths notes and falls back to normal symbols when needed +- [mono-column] #circlefree mode accepts non-16ths notes and falls back to normal symbols when needed ## Fixed - [jubeat-analyser] - Raise exception earlier when a mono-column file is detected by the other #memo parsers (based on "--" separator lines) - - [#memo] [#memo1] + - [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] + - [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 - [memon] @@ -24,7 +26,7 @@ - [jubeat-analyser] Use "EXT" instead of "?" as the fallback difficulty name when loading ## Fixed - [memon] Fix TypeError that would occur when trying to convert -- [#memo2] Fix rendering missing blank lines between blocks, while technically still valid files, this made files rendered by jubeatools absolutely fugly and very NOT human friendly +- [memo2] Fix rendering missing blank lines between blocks, while technically still valid files, this made files rendered by jubeatools absolutely fugly and very NOT human friendly # v0.1.2 ## Fixed @@ -34,7 +36,7 @@ # v0.1.1 ## Fixed -- [#memo2] Loading a file that did not specify any offset (neither by `o=...`, `r=...` nor `[...]` commands) would trigger a TypeError, not anymore ! Offset now defaults to zero. +- [memo2] Loading a file that did not specify any offset (neither by `o=...`, `r=...` nor `[...]` commands) would trigger a TypeError, not anymore ! Offset now defaults to zero. # v0.1.0 - Initial Release \ No newline at end of file diff --git a/jubeatools/formats/eve/__init__.py b/jubeatools/formats/eve/__init__.py index a37ab86..570dcb1 100644 --- a/jubeatools/formats/eve/__init__.py +++ b/jubeatools/formats/eve/__init__.py @@ -2,4 +2,5 @@ .eve is the file format used in arcade releases of jubeat """ -from .eve import load_eve, dump_eve +from .dump import dump_eve +from .load import load_eve diff --git a/jubeatools/formats/eve/commons.py b/jubeatools/formats/eve/commons.py new file mode 100644 index 0000000..407069a --- /dev/null +++ b/jubeatools/formats/eve/commons.py @@ -0,0 +1,148 @@ +from __future__ import annotations +from enum import Enum +from dataclasses import dataclass, astuple +from jubeatools import song +from typing import Union +from functools import singledispatchmethod +from fractions import Fraction + +from .timemap import TimeMap + +AnyNote = Union[song.TapNote, song.LongNote] + +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()} + +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 __post_init__(self) -> None: + try: + check_func = VALUES_CHECKERS[self.command](self.value) + except KeyError: + # most likely no check function associated : forget about it + pass + except ValueError as e: + raise ValueError(f"Invalid value for the {self.command!r} command. {e}") + + def dump(self) -> str: + return f"{self.time:>8},{self.command.name:<8},{self.value:>8}" + + + @classmethod + def from_tap_note(cls, 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) + + @classmethod + def from_long_note(cls, 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 is_zero(value: int) -> None: + if value != 0: + raise ValueError(f"Value should be zero but {value} found") + +def is_valid_button_index(value: int) -> None: + # Should raise ValueError if invalid + _ = song.NotePosition.from_index(value) + +def is_valid_tail_position(value: int) -> None: + # Should raise ValueError if invalid + _ = EveLong.from_value(value) + +def is_not_zero(value: int) -> None: + if value == 0: + raise ValueError(f"Value cannot be zero") + +VALUES_CHECKERS = { + Command.END: is_zero, + Command.MEASURE: is_zero, + Command.HAKU: is_zero, + Command.PLAY: is_valid_button_index, + Command.LONG: is_valid_tail_position, + Command.TEMPO: is_not_zero, +} + + +@dataclass +class EveLong: + duration: int + length: int + direction: song.Direction + position: int + + def __post_init__(self) -> None: + if self.duration < 0: + raise ValueError("Duration can't be negative") + if not 1 <= self.length < 4: + raise ValueError("Tail length must be between 1 and 3 inclusive") + pos = song.NotePosition.from_index(self.position) + step_vector = song.TAIL_DIRECTION_TO_OUTWARDS_VECTOR[self.direction] + tail_pos = pos + (self.length * step_vector) + if not ((0 <= tail_pos.x < 4) and (0 <= tail_pos.y < 4)): + raise ValueError( + f"Long note tail starts on {astuple(tail_pos)} which is " + "outside the screen" + ) + + @classmethod + def from_value(cls, value: int) -> EveLong: + ... + + +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) \ No newline at end of file diff --git a/jubeatools/formats/eve/eve.py b/jubeatools/formats/eve/dump.py similarity index 61% rename from jubeatools/formats/eve/eve.py rename to jubeatools/formats/eve/dump.py index 49c4e88..9c81227 100644 --- a/jubeatools/formats/eve/eve.py +++ b/jubeatools/formats/eve/dump.py @@ -1,70 +1,34 @@ from __future__ import annotations +from dataclasses import dataclass 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 typing import List, Union from more_itertools import numeric_range from jubeatools import song +from jubeatools.formats.dump_tools import make_dumper_from_chart_file_dumper 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()} +from .commons import AnyNote, Command, Event, DIRECTION_TO_VALUE 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') + 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") + internal_dumper=_dump_eve, file_name_template=Path("{difficulty_index}.eve") ) @@ -77,38 +41,20 @@ def dump_chart(notes: List[AnyNote], timing: song.Timing) -> str: def make_note_events(notes: List[AnyNote], time_map: TimeMap) -> List[Event]: - return [make_note_event(note, time_map) for note in notes] + return [Event.from_note(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}") - + raise NotImplementedError(f"Unknown note type : {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) - + return Event.from_tap_note(note, time_map) @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) + return Event.from_long_note(note, time_map) def make_timing_events( @@ -179,29 +125,3 @@ def make_beat_events(end_beat: song.BeatsTime, time_map: TimeMap) -> List[Event] 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/load.py b/jubeatools/formats/eve/load.py new file mode 100644 index 0000000..ab42706 --- /dev/null +++ b/jubeatools/formats/eve/load.py @@ -0,0 +1,69 @@ +from typing import List, Iterator +from pathlib import Path +from functools import reduce +from dataclasses import dataclass + +from jubeatools import song +from jubeatools.formats.load_tools import make_folder_loader + +from .commons import Event, Command + +def load_eve(path: Path) -> song.Song: + files = load_folder(path) + charts = [_load_eve(l, p) for p, l in files.items()] + return reduce(song.Song.merge, charts) + +def load_file(path: Path) -> List[str]: + return path.read_text(encoding="ascii").split("\n") + +load_folder = make_folder_loader("*.eve", load_file) + +def _load_eve(lines: List[str], file_path: Path) -> song.Song: + events = list(iter_events(lines)) + ... + +def iter_events(lines: List[str]) -> Iterator[Event]: + for i, raw_line in enumerate(lines, start=1): + line = raw_line.strip() + if not line: + continue + + try: + event = parser_event(line) + except ValueError as e: + raise ValueError( + f"Error on line {i} : {e}" + ) + +def parse_event(line: str) -> Event: + columns = line.split(",") + if len(columns) != 3: + raise ValueError(f"Expected 3 comma-separated values but found {len(columns)}") + + raw_tick, raw_command, raw_value = map(str.strip, columns) + try: + tick = int(raw_tick) + except ValueError: + raise ValueError( + f"The first column should contain an integer but {raw_tick!r} was " + f"found, which python could not understand as an integer" + ) + + try: + command = Command[raw_command] + except KeyError: + raise ValueError( + f"The second column should contain one of " + f"{list(Command.__members__)}, but {raw_command!r} was found" + ) + + try: + value = int(raw_value) + except ValueError: + raise ValueError( + f"The third column should contain an integer but {raw_tick!r} was " + f"found, which python could not understand as an integer" + ) + + return Event(time, command, value) +