.eve dumping support !
Refactor filename choice and folder loading logic to be usable by other formats change JubeatFile type to bytes create Direction enum rename LongNote.tail_is_straight to has_straight_tail create Difficulty enum rename Song.global_timing to common_timing
This commit is contained in:
parent
3a4dce98b0
commit
440b9f888d
78
jubeatools/formats/adapters.py
Normal file
78
jubeatools/formats/adapters.py
Normal file
@ -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}",
|
||||
)
|
15
jubeatools/formats/dump_tools.py
Normal file
15
jubeatools/formats/dump_tools.py
Normal file
@ -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,
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
"""
|
||||
.eve is the file format used in arcade releases of jubeat
|
||||
"""
|
||||
|
||||
from .eve import load_eve, dump_eve
|
||||
|
@ -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)
|
@ -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
|
||||
|
@ -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")
|
||||
|
||||
|
40
jubeatools/formats/files.py
Normal file
40
jubeatools/formats/files.py
Normal file
@ -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
|
@ -6,7 +6,7 @@ from jubeatools.song import Chart, Song
|
||||
|
||||
@dataclass
|
||||
class JubeatFile:
|
||||
contents: StringIO
|
||||
contents: bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -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,
|
||||
|
@ -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"),
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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)}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -23,7 +23,7 @@ data = (
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
common_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
||||
|
@ -26,7 +26,7 @@ data = (
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
common_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
||||
|
@ -30,7 +30,7 @@ data = (
|
||||
],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
common_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
||||
|
@ -26,7 +26,7 @@ data = (
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
common_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
||||
|
@ -26,7 +26,7 @@ data = (
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
common_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
||||
|
@ -26,7 +26,7 @@ data = (
|
||||
notes=[],
|
||||
)
|
||||
},
|
||||
global_timing=None,
|
||||
common_timing=None,
|
||||
),
|
||||
False,
|
||||
)
|
||||
|
@ -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),
|
||||
|
18
jubeatools/formats/jubeat_analyser/typing.py
Normal file
18
jubeatools/formats/jubeat_analyser/typing.py
Normal file
@ -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:
|
||||
...
|
@ -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")
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user