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
|
# Unreleased
|
||||||
|
## Added
|
||||||
|
- [eve] 🎉 Add support for .eve as output
|
||||||
## Fixed
|
## Fixed
|
||||||
- [jubeat-analyser] Prettier rendering of decimal values
|
- [jubeat-analyser] Prettier rendering of decimal values
|
||||||
|
|
||||||
# v0.2.0
|
# v0.2.0
|
||||||
## Added
|
## 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
|
## Fixed
|
||||||
- [jubeat-analyser]
|
- [jubeat-analyser]
|
||||||
- Raise exception earlier when a mono-column file is detected by the other #memo parsers (based on "--" separator lines)
|
- 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
|
- Fix incorrect handling of mid-chart `t=` and `b=` commands
|
||||||
- Prettify rendering by adding more blank lines between sections
|
- 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
|
- [memo1] Fix dumping of chart with bpm changes happening on beat times that aren't multiples of 1/4
|
||||||
- [#memo2]
|
- [memo2]
|
||||||
- Fix parsing of BPM changes
|
- Fix parsing of BPM changes
|
||||||
- Fix dumping of BPM changes
|
- Fix dumping of BPM changes
|
||||||
- [memon]
|
- [memon]
|
||||||
@ -24,7 +26,7 @@
|
|||||||
- [jubeat-analyser] Use "EXT" instead of "?" as the fallback difficulty name when loading
|
- [jubeat-analyser] Use "EXT" instead of "?" as the fallback difficulty name when loading
|
||||||
## Fixed
|
## Fixed
|
||||||
- [memon] Fix TypeError that would occur when trying to convert
|
- [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
|
# v0.1.2
|
||||||
## Fixed
|
## Fixed
|
||||||
@ -34,7 +36,7 @@
|
|||||||
|
|
||||||
# v0.1.1
|
# v0.1.1
|
||||||
## Fixed
|
## 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
|
# v0.1.0
|
||||||
- Initial Release
|
- Initial Release
|
@ -2,4 +2,5 @@
|
|||||||
.eve is the file format used in arcade releases of jubeat
|
.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 __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from typing import List, Union
|
|
||||||
from io import StringIO
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import singledispatch
|
from functools import singledispatch
|
||||||
import ctypes
|
|
||||||
import warnings
|
|
||||||
from typing import Dict, Tuple
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List, Union
|
||||||
|
|
||||||
from more_itertools import numeric_range
|
from more_itertools import numeric_range
|
||||||
|
|
||||||
from jubeatools import song
|
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.filetypes import ChartFile
|
||||||
from jubeatools.formats.adapters import make_dumper_from_chart_file_dumper
|
|
||||||
|
|
||||||
from .timemap import TimeMap
|
from .timemap import TimeMap
|
||||||
|
from .commons import AnyNote, Command, Event, DIRECTION_TO_VALUE
|
||||||
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]:
|
def _dump_eve(song: song.Song, **kwargs: dict) -> List[ChartFile]:
|
||||||
res = []
|
res = []
|
||||||
for dif, chart, timing in song.iter_charts_with_timing():
|
for dif, chart, timing in song.iter_charts_with_timing():
|
||||||
chart_text = dump_chart(chart.notes, 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))
|
res.append(ChartFile(chart_bytes, song, dif, chart))
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
dump_eve = make_dumper_from_chart_file_dumper(
|
dump_eve = make_dumper_from_chart_file_dumper(
|
||||||
internal_dumper=_dump_eve,
|
internal_dumper=_dump_eve, file_name_template=Path("{difficulty_index}.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]:
|
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
|
@singledispatch
|
||||||
def make_note_event(note: AnyNote, time_map: TimeMap) -> Event:
|
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
|
@make_note_event.register
|
||||||
def make_tap_note_event(note: song.TapNote, time_map: TimeMap) -> Event:
|
def make_tap_note_event(note: song.TapNote, time_map: TimeMap) -> Event:
|
||||||
ticks = ticks_at_beat(note.time, time_map)
|
return Event.from_tap_note(note, time_map)
|
||||||
value = note.position.index
|
|
||||||
return Event(time=ticks, command=Command.PLAY, value=value)
|
|
||||||
|
|
||||||
|
|
||||||
@make_note_event.register
|
@make_note_event.register
|
||||||
def make_long_note_event(note: song.LongNote, time_map: TimeMap) -> Event:
|
def make_long_note_event(note: song.LongNote, time_map: TimeMap) -> Event:
|
||||||
if not note.has_straight_tail():
|
return Event.from_long_note(note, time_map)
|
||||||
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(
|
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:
|
def make_beat_event(beat: song.BeatsTime, time_map: TimeMap) -> Event:
|
||||||
ticks = ticks_at_beat(beat, time_map)
|
ticks = ticks_at_beat(beat, time_map)
|
||||||
return Event(time=ticks, command=Command.HAKU, value=0)
|
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