Split eve between dump and load files
This commit is contained in:
parent
4d11287be2
commit
cb2bbfe370
14
CHANGELOG.md
14
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
|
@ -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
|
||||
|
148
jubeatools/formats/eve/commons.py
Normal file
148
jubeatools/formats/eve/commons.py
Normal file
@ -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)
|
@ -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)
|
69
jubeatools/formats/eve/load.py
Normal file
69
jubeatools/formats/eve/load.py
Normal file
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user