1
0
mirror of synced 2024-12-04 19:17:55 +01:00

.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:
Stepland 2021-05-08 00:36:13 +02:00
parent 3a4dce98b0
commit 440b9f888d
32 changed files with 555 additions and 215 deletions

View 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}",
)

View 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,
}

View File

@ -1,3 +1,5 @@
"""
.eve is the file format used in arcade releases of jubeat
"""
from .eve import load_eve, dump_eve

View File

@ -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)

View File

@ -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

View File

@ -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")

View 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

View File

@ -6,7 +6,7 @@ from jubeatools.song import Chart, Song
@dataclass
class JubeatFile:
contents: StringIO
contents: bytes
@dataclass

View File

@ -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,

View File

@ -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 = (
""
""
@ -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"),
)

View File

@ -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,
)

View 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)}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -23,7 +23,7 @@ data = (
notes=[],
)
},
global_timing=None,
common_timing=None,
),
False,
)

View File

@ -26,7 +26,7 @@ data = (
notes=[],
)
},
global_timing=None,
common_timing=None,
),
False,
)

View File

@ -30,7 +30,7 @@ data = (
],
)
},
global_timing=None,
common_timing=None,
),
False,
)

View File

@ -26,7 +26,7 @@ data = (
notes=[],
)
},
global_timing=None,
common_timing=None,
),
False,
)

View File

@ -26,7 +26,7 @@ data = (
notes=[],
)
},
global_timing=None,
common_timing=None,
),
False,
)

View File

@ -26,7 +26,7 @@ data = (
notes=[],
)
},
global_timing=None,
common_timing=None,
),
False,
)

View File

@ -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),

View 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:
...

View File

@ -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")

View File

@ -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]

View File

@ -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

View File

@ -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),
)