parent
6b869cfaf9
commit
42d2a3c118
@ -1,3 +1,7 @@
|
|||||||
|
# Unreleased
|
||||||
|
## Added
|
||||||
|
- [jbsq] 🎉 initial .jbsq support !
|
||||||
|
|
||||||
# 1.0.1
|
# 1.0.1
|
||||||
## Fixed
|
## Fixed
|
||||||
- Remove debug `print(locals())` mistakenly left in
|
- Remove debug `print(locals())` mistakenly left in
|
||||||
|
10
README.md
10
README.md
@ -17,10 +17,10 @@ jubeatools ${source} ${destination} -f ${output format} (... format specific opt
|
|||||||
| | | input | output |
|
| | | input | output |
|
||||||
|-----------------|----------------------|:-----:|:------:|
|
|-----------------|----------------------|:-----:|:------:|
|
||||||
| memon | v0.2.0 | ✔️ | ✔️ |
|
| memon | v0.2.0 | ✔️ | ✔️ |
|
||||||
| . | v0.1.0 | ✔️ | ✔️ |
|
| | v0.1.0 | ✔️ | ✔️ |
|
||||||
| . | legacy | ✔️ | ✔️ |
|
| | legacy | ✔️ | ✔️ |
|
||||||
| jubeat analyser | #memo2 | ✔️ | ✔️ |
|
| jubeat analyser | #memo2 | ✔️ | ✔️ |
|
||||||
| . | #memo1 | ✔️ | ✔️ |
|
| | #memo1 | ✔️ | ✔️ |
|
||||||
| . | #memo | ✔️ | ✔️ |
|
| | #memo | ✔️ | ✔️ |
|
||||||
| . | mono-column (1列形式) | ✔️ | ✔️ |
|
| | mono-column (1列形式) | ✔️ | ✔️ |
|
||||||
| jubeat (arcade) | .eve | ✔️ | ✔️ |
|
| jubeat (arcade) | .eve | ✔️ | ✔️ |
|
||||||
|
@ -3,6 +3,7 @@ from enum import Enum
|
|||||||
|
|
||||||
class Format(str, Enum):
|
class Format(str, Enum):
|
||||||
EVE = "eve"
|
EVE = "eve"
|
||||||
|
JBSQ = "jbsq"
|
||||||
MEMON_LEGACY = "memon:legacy"
|
MEMON_LEGACY = "memon:legacy"
|
||||||
MEMON_0_1_0 = "memon:v0.1.0"
|
MEMON_0_1_0 = "memon:v0.1.0"
|
||||||
MEMON_0_2_0 = "memon:v0.2.0"
|
MEMON_0_2_0 = "memon:v0.2.0"
|
||||||
|
@ -1,139 +0,0 @@
|
|||||||
from decimal import Decimal
|
|
||||||
from functools import reduce
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Iterator, List, Optional
|
|
||||||
|
|
||||||
from jubeatools import song
|
|
||||||
from jubeatools.formats.load_tools import make_folder_loader, round_beats
|
|
||||||
from jubeatools.utils import group_by
|
|
||||||
|
|
||||||
from .commons import (
|
|
||||||
VALUE_TO_DIRECTION,
|
|
||||||
AnyNote,
|
|
||||||
Command,
|
|
||||||
EveLong,
|
|
||||||
Event,
|
|
||||||
ticks_to_seconds,
|
|
||||||
value_to_truncated_bpm,
|
|
||||||
)
|
|
||||||
from .timemap import BPMAtSecond, TimeMap
|
|
||||||
|
|
||||||
|
|
||||||
def load_eve(path: Path, *, beat_snap: int = 240, **kwargs: Any) -> song.Song:
|
|
||||||
files = load_folder(path)
|
|
||||||
charts = [_load_eve(l, p, beat_snap=beat_snap) 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, *, beat_snap: int = 240) -> song.Song:
|
|
||||||
events = list(iter_events(lines))
|
|
||||||
events_by_command = group_by(events, lambda e: e.command)
|
|
||||||
bpms = [
|
|
||||||
BPMAtSecond(
|
|
||||||
seconds=ticks_to_seconds(e.time), BPM=value_to_truncated_bpm(e.value)
|
|
||||||
)
|
|
||||||
for e in sorted(events_by_command[Command.TEMPO])
|
|
||||||
]
|
|
||||||
time_map = TimeMap.from_seconds(bpms)
|
|
||||||
tap_notes: List[AnyNote] = [
|
|
||||||
make_tap_note(e.time, e.value, time_map, beat_snap)
|
|
||||||
for e in events_by_command[Command.PLAY]
|
|
||||||
]
|
|
||||||
long_notes: List[AnyNote] = [
|
|
||||||
make_long_note(e.time, e.value, time_map, beat_snap)
|
|
||||||
for e in events_by_command[Command.LONG]
|
|
||||||
]
|
|
||||||
all_notes = sorted(tap_notes + long_notes, key=lambda n: (n.time, n.position))
|
|
||||||
timing = time_map.convert_to_timing_info(beat_snap=beat_snap)
|
|
||||||
chart = song.Chart(level=Decimal(0), timing=timing, notes=all_notes)
|
|
||||||
dif = guess_difficulty(file_path.stem) or song.Difficulty.EXTREME
|
|
||||||
return song.Song(metadata=song.Metadata(), charts={dif: chart})
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
yield parse_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(tick, command, value)
|
|
||||||
|
|
||||||
|
|
||||||
def make_tap_note(
|
|
||||||
ticks: int, value: int, time_map: TimeMap, beat_snap: int
|
|
||||||
) -> song.TapNote:
|
|
||||||
seconds = ticks_to_seconds(ticks)
|
|
||||||
raw_beats = time_map.beats_at(seconds)
|
|
||||||
beats = round_beats(raw_beats, beat_snap)
|
|
||||||
position = song.NotePosition.from_index(value)
|
|
||||||
return song.TapNote(time=beats, position=position)
|
|
||||||
|
|
||||||
|
|
||||||
def make_long_note(
|
|
||||||
ticks: int, value: int, time_map: TimeMap, beat_snap: int
|
|
||||||
) -> song.LongNote:
|
|
||||||
seconds = ticks_to_seconds(ticks)
|
|
||||||
raw_beats = time_map.beats_at(seconds)
|
|
||||||
beats = round_beats(raw_beats, beat_snap)
|
|
||||||
eve_long = EveLong.from_value(value)
|
|
||||||
seconds_duration = ticks_to_seconds(eve_long.duration)
|
|
||||||
raw_beats_duration = time_map.beats_at(seconds + seconds_duration) - raw_beats
|
|
||||||
beats_duration = round_beats(raw_beats_duration, beat_snap)
|
|
||||||
position = song.NotePosition.from_index(eve_long.position)
|
|
||||||
direction = VALUE_TO_DIRECTION[eve_long.direction]
|
|
||||||
step_vector = song.TAIL_DIRECTION_TO_OUTWARDS_VECTOR[direction]
|
|
||||||
raw_tail_pos = position + (eve_long.length * step_vector)
|
|
||||||
tail_pos = song.NotePosition.from_raw_position(raw_tail_pos)
|
|
||||||
return song.LongNote(
|
|
||||||
time=beats, position=position, duration=beats_duration, tail_tip=tail_pos
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def guess_difficulty(filename: str) -> Optional[song.Difficulty]:
|
|
||||||
try:
|
|
||||||
return song.Difficulty(filename.upper())
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
@ -1,7 +1,6 @@
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from .enum import Format
|
from .enum import Format
|
||||||
from .eve import dump_eve, load_eve
|
|
||||||
from .jubeat_analyser import (
|
from .jubeat_analyser import (
|
||||||
dump_memo,
|
dump_memo,
|
||||||
dump_memo1,
|
dump_memo1,
|
||||||
@ -12,6 +11,7 @@ from .jubeat_analyser import (
|
|||||||
load_memo2,
|
load_memo2,
|
||||||
load_mono_column,
|
load_mono_column,
|
||||||
)
|
)
|
||||||
|
from .konami import dump_eve, dump_jbsq, load_eve, load_jbsq
|
||||||
from .memon import (
|
from .memon import (
|
||||||
dump_memon_0_1_0,
|
dump_memon_0_1_0,
|
||||||
dump_memon_0_2_0,
|
dump_memon_0_2_0,
|
||||||
@ -24,6 +24,7 @@ from .typing import Dumper, Loader
|
|||||||
|
|
||||||
LOADERS: Dict[Format, Loader] = {
|
LOADERS: Dict[Format, Loader] = {
|
||||||
Format.EVE: load_eve,
|
Format.EVE: load_eve,
|
||||||
|
Format.JBSQ: load_jbsq,
|
||||||
Format.MEMON_LEGACY: load_memon_legacy,
|
Format.MEMON_LEGACY: load_memon_legacy,
|
||||||
Format.MEMON_0_1_0: load_memon_0_1_0,
|
Format.MEMON_0_1_0: load_memon_0_1_0,
|
||||||
Format.MEMON_0_2_0: load_memon_0_2_0,
|
Format.MEMON_0_2_0: load_memon_0_2_0,
|
||||||
@ -35,6 +36,7 @@ LOADERS: Dict[Format, Loader] = {
|
|||||||
|
|
||||||
DUMPERS: Dict[Format, Dumper] = {
|
DUMPERS: Dict[Format, Dumper] = {
|
||||||
Format.EVE: dump_eve,
|
Format.EVE: dump_eve,
|
||||||
|
Format.JBSQ: dump_jbsq,
|
||||||
Format.MEMON_LEGACY: dump_memon_legacy,
|
Format.MEMON_LEGACY: dump_memon_legacy,
|
||||||
Format.MEMON_0_1_0: dump_memon_0_1_0,
|
Format.MEMON_0_1_0: dump_memon_0_1_0,
|
||||||
Format.MEMON_0_2_0: dump_memon_0_2_0,
|
Format.MEMON_0_2_0: dump_memon_0_2_0,
|
||||||
|
@ -22,6 +22,9 @@ def guess_format(path: Path) -> Format:
|
|||||||
if looks_like_eve(path):
|
if looks_like_eve(path):
|
||||||
return Format.EVE
|
return Format.EVE
|
||||||
|
|
||||||
|
if looks_like_jbsq(path):
|
||||||
|
return Format.JBSQ
|
||||||
|
|
||||||
raise ValueError("Unrecognized file format")
|
raise ValueError("Unrecognized file format")
|
||||||
|
|
||||||
|
|
||||||
@ -94,7 +97,11 @@ def recognize_jubeat_analyser_format(path: Path) -> Format:
|
|||||||
|
|
||||||
def looks_like_eve(path: Path) -> bool:
|
def looks_like_eve(path: Path) -> bool:
|
||||||
with path.open(encoding="ascii") as f:
|
with path.open(encoding="ascii") as f:
|
||||||
|
try:
|
||||||
line = f.readline()
|
line = f.readline()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return False
|
||||||
|
|
||||||
if line.strip():
|
if line.strip():
|
||||||
return looks_like_eve_line(next(f))
|
return looks_like_eve_line(next(f))
|
||||||
|
|
||||||
@ -131,3 +138,8 @@ def looks_like_eve_line(line: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_jbsq(path: Path) -> bool:
|
||||||
|
magic = path.open(mode="rb").read(4)
|
||||||
|
return magic in (b"IJBQ", b"IJSQ", b"JBSQ")
|
||||||
|
2
jubeatools/formats/konami/__init__.py
Normal file
2
jubeatools/formats/konami/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from .eve import dump_eve, load_eve
|
||||||
|
from .jbsq import dump_jbsq, load_jbsq
|
@ -13,6 +13,7 @@ from .timemap import TimeMap
|
|||||||
|
|
||||||
AnyNote = Union[song.TapNote, song.LongNote]
|
AnyNote = Union[song.TapNote, song.LongNote]
|
||||||
|
|
||||||
|
|
||||||
DIRECTION_TO_VALUE = {
|
DIRECTION_TO_VALUE = {
|
||||||
song.Direction.DOWN: 0,
|
song.Direction.DOWN: 0,
|
||||||
song.Direction.UP: 1,
|
song.Direction.UP: 1,
|
||||||
@ -34,7 +35,7 @@ class Command(int, Enum):
|
|||||||
|
|
||||||
@dataclass(order=True)
|
@dataclass(order=True)
|
||||||
class Event:
|
class Event:
|
||||||
"""Represents a line in an .eve file"""
|
"""Represents a line in an .eve file or an event struct in a .jbsq file"""
|
||||||
|
|
||||||
time: int
|
time: int
|
||||||
command: Command
|
command: Command
|
@ -1,42 +1,21 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from functools import singledispatch
|
from functools import singledispatch
|
||||||
from pathlib import Path
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
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 .commons import AnyNote, Command, Event, bpm_to_value, ticks_at_beat
|
from .commons import AnyNote, Command, Event, bpm_to_value, ticks_at_beat
|
||||||
from .timemap import TimeMap
|
from .timemap import TimeMap
|
||||||
|
|
||||||
|
|
||||||
def _dump_eve(song: song.Song, **kwargs: dict) -> List[ChartFile]:
|
def make_events_from_chart(notes: List[AnyNote], timing: song.Timing) -> List[Event]:
|
||||||
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:l}.eve")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def dump_chart(notes: List[AnyNote], timing: song.Timing) -> str:
|
|
||||||
time_map = TimeMap.from_timing(timing)
|
time_map = TimeMap.from_timing(timing)
|
||||||
note_events = make_note_events(notes, time_map)
|
note_events = make_note_events(notes, time_map)
|
||||||
timing_events = make_timing_events(notes, timing, time_map)
|
timing_events = make_timing_events(notes, timing, time_map)
|
||||||
sorted_events = sorted(note_events + timing_events)
|
return 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]:
|
def make_note_events(notes: List[AnyNote], time_map: TimeMap) -> List[Event]:
|
24
jubeatools/formats/konami/eve/dump.py
Normal file
24
jubeatools/formats/konami/eve/dump.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from jubeatools import song
|
||||||
|
from jubeatools.formats.dump_tools import make_dumper_from_chart_file_dumper
|
||||||
|
from jubeatools.formats.filetypes import ChartFile
|
||||||
|
|
||||||
|
from ..dump_tools import make_events_from_chart
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_eve(song: song.Song, **kwargs: dict) -> List[ChartFile]:
|
||||||
|
res = []
|
||||||
|
for dif, chart, timing in song.iter_charts_with_timing():
|
||||||
|
events = make_events_from_chart(chart.notes, timing)
|
||||||
|
chart_text = "\n".join(e.dump() for e in events)
|
||||||
|
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:l}.eve")
|
||||||
|
)
|
80
jubeatools/formats/konami/eve/load.py
Normal file
80
jubeatools/formats/konami/eve/load.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
from functools import reduce
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator, List, Optional
|
||||||
|
|
||||||
|
from jubeatools import song
|
||||||
|
from jubeatools.formats.load_tools import make_folder_loader
|
||||||
|
|
||||||
|
from ..commons import Command, Event
|
||||||
|
from ..load_tools import make_chart_from_events
|
||||||
|
|
||||||
|
|
||||||
|
def load_eve(path: Path, *, beat_snap: int = 240, **kwargs: Any) -> song.Song:
|
||||||
|
files = load_folder(path)
|
||||||
|
charts = [_load_eve(l, p, beat_snap=beat_snap) 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, *, beat_snap: int = 240) -> song.Song:
|
||||||
|
chart = make_chart_from_events(iter_events(lines), beat_snap=beat_snap)
|
||||||
|
dif = guess_difficulty(file_path.stem) or song.Difficulty.EXTREME
|
||||||
|
return song.Song(metadata=song.Metadata(), charts={dif: chart})
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
yield parse_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(tick, command, value)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_difficulty(filename: str) -> Optional[song.Difficulty]:
|
||||||
|
try:
|
||||||
|
return song.Difficulty(filename.upper())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
@ -3,7 +3,7 @@ import math
|
|||||||
from hypothesis import given
|
from hypothesis import given
|
||||||
from hypothesis import strategies as st
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
from ..commons import bpm_to_value, value_to_truncated_bpm
|
from jubeatools.formats.konami.commons import bpm_to_value, value_to_truncated_bpm
|
||||||
|
|
||||||
|
|
||||||
@given(st.integers(min_value=1, max_value=6 * 10 ** 7))
|
@given(st.integers(min_value=1, max_value=6 * 10 ** 7))
|
17
jubeatools/formats/konami/eve/tests/test_eve.py
Normal file
17
jubeatools/formats/konami/eve/tests/test_eve.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from hypothesis import given
|
||||||
|
|
||||||
|
from jubeatools import song
|
||||||
|
from jubeatools.formats import Format
|
||||||
|
from jubeatools.formats.konami.testutils import eve_compatible_song, open_temp_dir
|
||||||
|
from jubeatools.testutils.test_patterns import dump_and_load_then_compare
|
||||||
|
|
||||||
|
|
||||||
|
@given(eve_compatible_song())
|
||||||
|
def test_that_full_chart_roundtrips(song: song.Song) -> None:
|
||||||
|
dump_and_load_then_compare(
|
||||||
|
Format.EVE,
|
||||||
|
song,
|
||||||
|
temp_path=open_temp_dir(),
|
||||||
|
bytes_decoder=lambda b: b.decode("ascii"),
|
||||||
|
load_options={"beat_snap": 24},
|
||||||
|
)
|
@ -2,11 +2,10 @@ from hypothesis import given
|
|||||||
from hypothesis import strategies as st
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
from jubeatools import song
|
from jubeatools import song
|
||||||
|
from jubeatools.formats.konami.commons import EveLong
|
||||||
|
from jubeatools.formats.konami.timemap import TimeMap
|
||||||
from jubeatools.testutils import strategies as jbst
|
from jubeatools.testutils import strategies as jbst
|
||||||
|
|
||||||
from ..commons import EveLong
|
|
||||||
from ..timemap import TimeMap
|
|
||||||
|
|
||||||
|
|
||||||
@given(
|
@given(
|
||||||
jbst.long_note(),
|
jbst.long_note(),
|
@ -3,7 +3,7 @@ from fractions import Fraction
|
|||||||
from hypothesis import given
|
from hypothesis import given
|
||||||
|
|
||||||
from jubeatools import song
|
from jubeatools import song
|
||||||
from jubeatools.formats.eve.timemap import TimeMap
|
from jubeatools.formats.konami.timemap import TimeMap
|
||||||
from jubeatools.testutils import strategies as jbst
|
from jubeatools.testutils import strategies as jbst
|
||||||
from jubeatools.utils import group_by
|
from jubeatools.utils import group_by
|
||||||
|
|
8
jubeatools/formats/konami/jbsq/__init__.py
Normal file
8
jubeatools/formats/konami/jbsq/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
""".jbsq is the file format used in the iOS and Android app Jubeat plus
|
||||||
|
|
||||||
|
It's a binary version of the .eve format, with the same limitations.
|
||||||
|
|
||||||
|
I wanted to try kaitai for this but it doesn't support serializing right now"""
|
||||||
|
|
||||||
|
from .dump import dump_jbsq
|
||||||
|
from .load import load_jbsq
|
43
jubeatools/formats/konami/jbsq/construct.py
Normal file
43
jubeatools/formats/konami/jbsq/construct.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""The JBSQ format described using construct.
|
||||||
|
see https://construct.readthedocs.io/en/latest/index.html"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import construct as c
|
||||||
|
import construct_typed as ct
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(ct.EnumBase):
|
||||||
|
PLAY = 1
|
||||||
|
END = 2
|
||||||
|
MEASURE = 3
|
||||||
|
HAKU = 4
|
||||||
|
TEMPO = 5
|
||||||
|
LONG = 6
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Event(ct.TContainerMixin):
|
||||||
|
type_: EventType = ct.sfield(ct.TEnum(c.Byte, EventType))
|
||||||
|
time_in_ticks: int = ct.sfield(c.Int24ul)
|
||||||
|
value: int = ct.sfield(c.Int32ul)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JBSQ(ct.TContainerMixin):
|
||||||
|
magic: Optional[bytes] = ct.sfield(
|
||||||
|
c.Select(c.Const(b"IJBQ"), c.Const(b"IJSQ"), c.Const(b"JBSQ"))
|
||||||
|
)
|
||||||
|
num_events: int = ct.sfield(c.Int32ul)
|
||||||
|
combo: int = ct.sfield(c.Int32ul)
|
||||||
|
end_time: int = ct.sfield(c.Int32ul)
|
||||||
|
_1: None = ct.sfield(c.Padding(2))
|
||||||
|
starting_buttons: int = ct.sfield(c.Int16ul)
|
||||||
|
start_time: int = ct.sfield(c.Int32ul)
|
||||||
|
_2: None = ct.sfield(c.Padding(12))
|
||||||
|
density_graph: List[int] = ct.sfield(c.Byte[60])
|
||||||
|
events: List[Event] = ct.sfield(c.Array(c.this.num_events, ct.TStruct(Event)))
|
||||||
|
|
||||||
|
|
||||||
|
jbsq = ct.TStruct(JBSQ)
|
102
jubeatools/formats/konami/jbsq/dump.py
Normal file
102
jubeatools/formats/konami/jbsq/dump.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import DefaultDict, List
|
||||||
|
|
||||||
|
from jubeatools import song
|
||||||
|
from jubeatools.formats.dump_tools import make_dumper_from_chart_file_dumper
|
||||||
|
from jubeatools.formats.filetypes import ChartFile
|
||||||
|
from jubeatools.utils import group_by
|
||||||
|
|
||||||
|
from .. import commons as konami
|
||||||
|
from ..commons import AnyNote
|
||||||
|
from ..dump_tools import make_events_from_chart
|
||||||
|
from . import construct
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_jbsq(song: song.Song, **kwargs: dict) -> List[ChartFile]:
|
||||||
|
res = []
|
||||||
|
for dif, chart, timing in song.iter_charts_with_timing():
|
||||||
|
events = make_events_from_chart(chart.notes, timing)
|
||||||
|
jbsq_chart = make_jbsq_chart(events, chart.notes)
|
||||||
|
chart_bytes = construct.jbsq.build(jbsq_chart)
|
||||||
|
res.append(ChartFile(chart_bytes, song, dif, chart))
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
dump_jbsq = make_dumper_from_chart_file_dumper(
|
||||||
|
internal_dumper=_dump_jbsq, file_name_template=Path("seq_{difficulty:l}.jbsq")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_jbsq_chart(events: List[konami.Event], notes: List[AnyNote]) -> construct.JBSQ:
|
||||||
|
jbsq_events = [convert_event_to_jbsq(e) for e in events]
|
||||||
|
num_events = len(events)
|
||||||
|
combo = compute_max_combo(notes)
|
||||||
|
end_time = next(e for e in events if e.command == konami.Command.END).time
|
||||||
|
first_note_time_in_beats = min((n.time for n in notes), default=0)
|
||||||
|
starting_notes = [n for n in notes if n.time == first_note_time_in_beats]
|
||||||
|
starting_buttons = sum(1 << n.position.index for n in starting_notes)
|
||||||
|
first_note_time = min(
|
||||||
|
(
|
||||||
|
e.time
|
||||||
|
for e in events
|
||||||
|
if e.command in (konami.Command.PLAY, konami.Command.LONG)
|
||||||
|
),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
densities = compute_density_graph(events, end_time)
|
||||||
|
jbsq_chart = construct.JBSQ(
|
||||||
|
num_events=num_events,
|
||||||
|
combo=combo,
|
||||||
|
end_time=end_time,
|
||||||
|
starting_buttons=starting_buttons,
|
||||||
|
start_time=first_note_time,
|
||||||
|
density_graph=densities,
|
||||||
|
events=jbsq_events,
|
||||||
|
)
|
||||||
|
jbsq_chart.magic = b"JBSQ"
|
||||||
|
return jbsq_chart
|
||||||
|
|
||||||
|
|
||||||
|
def convert_event_to_jbsq(event: konami.Event) -> construct.Event:
|
||||||
|
return construct.Event(
|
||||||
|
type_=construct.EventType[event.command.name],
|
||||||
|
time_in_ticks=event.time,
|
||||||
|
value=event.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_max_combo(notes: List[AnyNote]) -> int:
|
||||||
|
notes_by_type = group_by(notes, type)
|
||||||
|
tap_notes = len(notes_by_type[song.TapNote])
|
||||||
|
long_notes = len(notes_by_type[song.LongNote])
|
||||||
|
return tap_notes + 2 * long_notes
|
||||||
|
|
||||||
|
|
||||||
|
def compute_density_graph(events: List[konami.Event], end_time: int) -> List[int]:
|
||||||
|
events_by_type = group_by(events, lambda e: e.command)
|
||||||
|
buckets: DefaultDict[int, int] = defaultdict(int)
|
||||||
|
for tap in events_by_type[konami.Command.PLAY]:
|
||||||
|
bucket = int((tap.time / end_time) * 120)
|
||||||
|
buckets[bucket] += 1
|
||||||
|
|
||||||
|
for long in events_by_type[konami.Command.LONG]:
|
||||||
|
press_bucket = int((long.time / end_time) * 120)
|
||||||
|
buckets[press_bucket] += 1
|
||||||
|
duration = konami.EveLong.from_value(long.value).duration
|
||||||
|
release_time = long.time + duration
|
||||||
|
release_bucket = int((release_time / end_time) * 120)
|
||||||
|
buckets[release_bucket] += 1
|
||||||
|
|
||||||
|
res = []
|
||||||
|
for i in range(0, 120, 2):
|
||||||
|
# The jbsq density graph in a array of nibbles, the twist is that for
|
||||||
|
# some obscure reason each pair of nibbles is swapped in the byte ...
|
||||||
|
# little-endianness is a hell of a drug, don't do drugs kids ...
|
||||||
|
first_nibble = min(buckets[i], 15)
|
||||||
|
second_nibble = min(buckets[i + 1], 15)
|
||||||
|
density_byte = (second_nibble << 4) + first_nibble
|
||||||
|
res.append(density_byte)
|
||||||
|
|
||||||
|
return res
|
105
jubeatools/formats/konami/jbsq/jbsq.ksy
Normal file
105
jubeatools/formats/konami/jbsq/jbsq.ksy
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
meta:
|
||||||
|
id: jbsq
|
||||||
|
endian: le
|
||||||
|
bit-endian: le
|
||||||
|
seq:
|
||||||
|
- id: magic
|
||||||
|
type: u4be
|
||||||
|
enum: magic_bytes
|
||||||
|
- id: num_events
|
||||||
|
type: u4
|
||||||
|
- id: combo
|
||||||
|
type: u4
|
||||||
|
- id: end_time
|
||||||
|
type: u4
|
||||||
|
- id: reserved1
|
||||||
|
size: 2
|
||||||
|
- id: starting_buttons
|
||||||
|
type: u2
|
||||||
|
- id: first_sector
|
||||||
|
type: u4
|
||||||
|
- id: reserved2
|
||||||
|
size: 12
|
||||||
|
- id: density_graph
|
||||||
|
type: b4
|
||||||
|
repeat: expr
|
||||||
|
repeat-expr: 120
|
||||||
|
- id: events
|
||||||
|
type: event
|
||||||
|
repeat: expr
|
||||||
|
repeat-expr: num_events
|
||||||
|
enums:
|
||||||
|
magic_bytes:
|
||||||
|
0x494a4251: ijbq
|
||||||
|
0x494a5351: ijsq
|
||||||
|
0x4a425351: jbsq
|
||||||
|
command:
|
||||||
|
1: play
|
||||||
|
2: end
|
||||||
|
3: measure
|
||||||
|
4: haku
|
||||||
|
5: tempo
|
||||||
|
6: long
|
||||||
|
direction:
|
||||||
|
0: down
|
||||||
|
1: up
|
||||||
|
2: right
|
||||||
|
3: left
|
||||||
|
types:
|
||||||
|
event:
|
||||||
|
seq:
|
||||||
|
- id: type
|
||||||
|
type: b8
|
||||||
|
enum: command
|
||||||
|
- id: time_in_ticks
|
||||||
|
type: b24
|
||||||
|
- id: value
|
||||||
|
type:
|
||||||
|
switch-on: type
|
||||||
|
cases:
|
||||||
|
'command::play': play_value
|
||||||
|
'command::end': zero_value
|
||||||
|
'command::measure': zero_value
|
||||||
|
'command::haku': zero_value
|
||||||
|
'command::tempo': tempo_value
|
||||||
|
'command::long': long_value
|
||||||
|
instances:
|
||||||
|
time_in_seconds:
|
||||||
|
value: time_in_ticks / 300.0
|
||||||
|
zero_value:
|
||||||
|
seq:
|
||||||
|
- id: value
|
||||||
|
contents: [0, 0, 0, 0]
|
||||||
|
play_value:
|
||||||
|
seq:
|
||||||
|
- id: note_position
|
||||||
|
type: position
|
||||||
|
- id: reserved
|
||||||
|
type: b28
|
||||||
|
tempo_value:
|
||||||
|
seq:
|
||||||
|
- id: tempo
|
||||||
|
type: u4
|
||||||
|
instances:
|
||||||
|
bpm:
|
||||||
|
value: 60000000.0 / tempo
|
||||||
|
long_value:
|
||||||
|
seq:
|
||||||
|
- id: note_position
|
||||||
|
type: position
|
||||||
|
- id: tail_direction
|
||||||
|
type: b2
|
||||||
|
enum: direction
|
||||||
|
- id: tail_length
|
||||||
|
type: b2
|
||||||
|
- id: duration_in_ticks
|
||||||
|
type: b24
|
||||||
|
instances:
|
||||||
|
duration_in_seconds:
|
||||||
|
value: duration_in_ticks / 300.0
|
||||||
|
position:
|
||||||
|
seq:
|
||||||
|
- id: x
|
||||||
|
type: b2
|
||||||
|
- id: y
|
||||||
|
type: b2
|
51
jubeatools/formats/konami/jbsq/load.py
Normal file
51
jubeatools/formats/konami/jbsq/load.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from functools import reduce
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from jubeatools import song
|
||||||
|
from jubeatools.formats.load_tools import make_folder_loader
|
||||||
|
|
||||||
|
from .. import commons as konami
|
||||||
|
from ..load_tools import make_chart_from_events
|
||||||
|
from . import construct
|
||||||
|
|
||||||
|
|
||||||
|
def load_jbsq(path: Path, *, beat_snap: int = 240, **kwargs: Any) -> song.Song:
|
||||||
|
files = load_folder(path)
|
||||||
|
charts = [
|
||||||
|
load_jbsq_file(bytes_, path, beat_snap=beat_snap)
|
||||||
|
for path, bytes_ in files.items()
|
||||||
|
]
|
||||||
|
return reduce(song.Song.merge, charts)
|
||||||
|
|
||||||
|
|
||||||
|
def load_file(path: Path) -> bytes:
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
load_folder = make_folder_loader("*.jbsq", load_file)
|
||||||
|
|
||||||
|
|
||||||
|
def load_jbsq_file(
|
||||||
|
bytes_: bytes, file_path: Path, *, beat_snap: int = 240
|
||||||
|
) -> song.Song:
|
||||||
|
raw_data = construct.jbsq.parse(bytes_)
|
||||||
|
events = [make_event_from_construct(e) for e in raw_data.events]
|
||||||
|
chart = make_chart_from_events(events, beat_snap=beat_snap)
|
||||||
|
dif = guess_difficulty(file_path.stem) or song.Difficulty.EXTREME
|
||||||
|
return song.Song(metadata=song.Metadata(), charts={dif: chart})
|
||||||
|
|
||||||
|
|
||||||
|
def make_event_from_construct(e: construct.Event) -> konami.Event:
|
||||||
|
return konami.Event(
|
||||||
|
time=e.time_in_ticks,
|
||||||
|
command=konami.Command[e.type_.name],
|
||||||
|
value=e.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_difficulty(filename: str) -> Optional[song.Difficulty]:
|
||||||
|
try:
|
||||||
|
return song.Difficulty(filename[-3:].upper())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
0
jubeatools/formats/konami/jbsq/tests/__init__.py
Normal file
0
jubeatools/formats/konami/jbsq/tests/__init__.py
Normal file
19
jubeatools/formats/konami/jbsq/tests/test_jbsq.py
Normal file
19
jubeatools/formats/konami/jbsq/tests/test_jbsq.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from hypothesis import given
|
||||||
|
|
||||||
|
from jubeatools import song
|
||||||
|
from jubeatools.formats import Format
|
||||||
|
from jubeatools.formats.konami.testutils import eve_compatible_song, open_temp_dir
|
||||||
|
from jubeatools.testutils.test_patterns import dump_and_load_then_compare
|
||||||
|
|
||||||
|
from ..construct import jbsq
|
||||||
|
|
||||||
|
|
||||||
|
@given(eve_compatible_song())
|
||||||
|
def test_that_full_chart_roundtrips(song: song.Song) -> None:
|
||||||
|
dump_and_load_then_compare(
|
||||||
|
Format.JBSQ,
|
||||||
|
song,
|
||||||
|
temp_path=open_temp_dir(),
|
||||||
|
bytes_decoder=lambda b: str(jbsq.parse(b)),
|
||||||
|
load_options={"beat_snap": 24},
|
||||||
|
)
|
69
jubeatools/formats/konami/load_tools.py
Normal file
69
jubeatools/formats/konami/load_tools.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from typing import Iterable, List
|
||||||
|
|
||||||
|
from jubeatools import song
|
||||||
|
from jubeatools.formats.load_tools import round_beats
|
||||||
|
from jubeatools.utils import group_by
|
||||||
|
|
||||||
|
from .commons import (
|
||||||
|
VALUE_TO_DIRECTION,
|
||||||
|
AnyNote,
|
||||||
|
Command,
|
||||||
|
EveLong,
|
||||||
|
Event,
|
||||||
|
ticks_to_seconds,
|
||||||
|
value_to_truncated_bpm,
|
||||||
|
)
|
||||||
|
from .timemap import BPMAtSecond, TimeMap
|
||||||
|
|
||||||
|
|
||||||
|
def make_chart_from_events(events: Iterable[Event], beat_snap: int = 240) -> song.Chart:
|
||||||
|
events_by_command = group_by(events, lambda e: e.command)
|
||||||
|
bpms = [
|
||||||
|
BPMAtSecond(
|
||||||
|
seconds=ticks_to_seconds(e.time), BPM=value_to_truncated_bpm(e.value)
|
||||||
|
)
|
||||||
|
for e in sorted(events_by_command[Command.TEMPO])
|
||||||
|
]
|
||||||
|
time_map = TimeMap.from_seconds(bpms)
|
||||||
|
tap_notes: List[AnyNote] = [
|
||||||
|
make_tap_note(e.time, e.value, time_map, beat_snap)
|
||||||
|
for e in events_by_command[Command.PLAY]
|
||||||
|
]
|
||||||
|
long_notes: List[AnyNote] = [
|
||||||
|
make_long_note(e.time, e.value, time_map, beat_snap)
|
||||||
|
for e in events_by_command[Command.LONG]
|
||||||
|
]
|
||||||
|
all_notes = sorted(tap_notes + long_notes, key=lambda n: (n.time, n.position))
|
||||||
|
timing = time_map.convert_to_timing_info(beat_snap=beat_snap)
|
||||||
|
return song.Chart(level=Decimal(0), timing=timing, notes=all_notes)
|
||||||
|
|
||||||
|
|
||||||
|
def make_tap_note(
|
||||||
|
ticks: int, value: int, time_map: TimeMap, beat_snap: int
|
||||||
|
) -> song.TapNote:
|
||||||
|
seconds = ticks_to_seconds(ticks)
|
||||||
|
raw_beats = time_map.beats_at(seconds)
|
||||||
|
beats = round_beats(raw_beats, beat_snap)
|
||||||
|
position = song.NotePosition.from_index(value)
|
||||||
|
return song.TapNote(time=beats, position=position)
|
||||||
|
|
||||||
|
|
||||||
|
def make_long_note(
|
||||||
|
ticks: int, value: int, time_map: TimeMap, beat_snap: int
|
||||||
|
) -> song.LongNote:
|
||||||
|
seconds = ticks_to_seconds(ticks)
|
||||||
|
raw_beats = time_map.beats_at(seconds)
|
||||||
|
beats = round_beats(raw_beats, beat_snap)
|
||||||
|
eve_long = EveLong.from_value(value)
|
||||||
|
seconds_duration = ticks_to_seconds(eve_long.duration)
|
||||||
|
raw_beats_duration = time_map.beats_at(seconds + seconds_duration) - raw_beats
|
||||||
|
beats_duration = round_beats(raw_beats_duration, beat_snap)
|
||||||
|
position = song.NotePosition.from_index(eve_long.position)
|
||||||
|
direction = VALUE_TO_DIRECTION[eve_long.direction]
|
||||||
|
step_vector = song.TAIL_DIRECTION_TO_OUTWARDS_VECTOR[direction]
|
||||||
|
raw_tail_pos = position + (eve_long.length * step_vector)
|
||||||
|
tail_pos = song.NotePosition.from_raw_position(raw_tail_pos)
|
||||||
|
return song.LongNote(
|
||||||
|
time=beats, position=position, duration=beats_duration, tail_tip=tail_pos
|
||||||
|
)
|
@ -4,13 +4,10 @@ from decimal import Decimal
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
|
||||||
from hypothesis import given
|
|
||||||
from hypothesis import strategies as st
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
from jubeatools import song
|
from jubeatools import song
|
||||||
from jubeatools.formats import Format
|
|
||||||
from jubeatools.testutils import strategies as jbst
|
from jubeatools.testutils import strategies as jbst
|
||||||
from jubeatools.testutils.test_patterns import dump_and_load_then_compare
|
|
||||||
from jubeatools.testutils.typing import DrawFunc
|
from jubeatools.testutils.typing import DrawFunc
|
||||||
|
|
||||||
simple_beat_strat = jbst.beat_time(
|
simple_beat_strat = jbst.beat_time(
|
||||||
@ -62,14 +59,3 @@ def eve_compatible_song(draw: DrawFunc) -> song.Song:
|
|||||||
def open_temp_dir() -> Iterator[Path]:
|
def open_temp_dir() -> Iterator[Path]:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
yield Path(temp_dir)
|
yield Path(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
@given(eve_compatible_song())
|
|
||||||
def test_that_full_chart_roundtrips(song: song.Song) -> None:
|
|
||||||
dump_and_load_then_compare(
|
|
||||||
Format.EVE,
|
|
||||||
song,
|
|
||||||
temp_path=open_temp_dir(),
|
|
||||||
bytes_decoder=lambda b: b.decode("ascii"),
|
|
||||||
load_options={"beat_snap": 24},
|
|
||||||
)
|
|
@ -6,7 +6,7 @@ from decimal import Decimal
|
|||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from math import gcd
|
from math import gcd
|
||||||
from typing import Callable, Dict, Hashable, List, Optional, TypeVar
|
from typing import Callable, Dict, Hashable, Iterable, List, Optional, TypeVar
|
||||||
|
|
||||||
|
|
||||||
def single_lcm(a: int, b: int) -> int:
|
def single_lcm(a: int, b: int) -> int:
|
||||||
@ -44,7 +44,7 @@ K = TypeVar("K", bound=Hashable)
|
|||||||
V = TypeVar("V")
|
V = TypeVar("V")
|
||||||
|
|
||||||
|
|
||||||
def group_by(elements: List[V], key: Callable[[V], K]) -> Dict[K, List[V]]:
|
def group_by(elements: Iterable[V], key: Callable[[V], K]) -> Dict[K, List[V]]:
|
||||||
res = defaultdict(list)
|
res = defaultdict(list)
|
||||||
for e in elements:
|
for e in elements:
|
||||||
res[key(e)].append(e)
|
res[key(e)].append(e)
|
||||||
|
31
poetry.lock
generated
31
poetry.lock
generated
@ -90,6 +90,28 @@ category = "dev"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "construct"
|
||||||
|
version = "2.10.66"
|
||||||
|
description = "A powerful declarative symmetric parser/builder for binary data"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
extras = ["arrow", "cloudpickle", "enum34", "lz4", "numpy", "ruamel.yaml"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "construct-typing"
|
||||||
|
version = "0.4.2"
|
||||||
|
description = "Extension for the python package 'construct' that adds typing features"
|
||||||
|
category = "dev"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
construct = "2.10.66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flake8"
|
name = "flake8"
|
||||||
version = "3.9.1"
|
version = "3.9.1"
|
||||||
@ -419,7 +441,7 @@ python-versions = "*"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.8"
|
python-versions = "^3.8"
|
||||||
content-hash = "4065f5a647b6cfb73d1aeceaae1fee4f94ecccdaa807aa134d3c307c6ecfbbd9"
|
content-hash = "8831aec0d2e864364d6b1681b19f918072cb4acf2fc35fdac8934d107335ce0a"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
appdirs = [
|
appdirs = [
|
||||||
@ -453,6 +475,13 @@ colorama = [
|
|||||||
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
|
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
|
||||||
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
|
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
|
||||||
]
|
]
|
||||||
|
construct = [
|
||||||
|
{file = "construct-2.10.66.tar.gz", hash = "sha256:268ab261e8d6a34247038d25e6c7bad831c2b0fc9ef782308c232ecadc89e5d0"},
|
||||||
|
]
|
||||||
|
construct-typing = [
|
||||||
|
{file = "construct-typing-0.4.2.tar.gz", hash = "sha256:e19be4f4ad437e28a11d1675c52d3485fe37b4f49edbfe94e219059954004094"},
|
||||||
|
{file = "construct_typing-0.4.2-py3-none-any.whl", hash = "sha256:5b6b7322005f4859cb6b6bcb5dec965de29d21168c599a9100429b19fa85aaee"},
|
||||||
|
]
|
||||||
flake8 = [
|
flake8 = [
|
||||||
{file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"},
|
{file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"},
|
||||||
{file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"},
|
{file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"},
|
||||||
|
@ -17,6 +17,7 @@ parsimonious = "^0.8.1"
|
|||||||
more-itertools = "^8.4.0"
|
more-itertools = "^8.4.0"
|
||||||
sortedcontainers = "^2.3.0"
|
sortedcontainers = "^2.3.0"
|
||||||
python-constraint = "^1.4.0"
|
python-constraint = "^1.4.0"
|
||||||
|
construct = "~=2.10"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^6.2.3"
|
pytest = "^6.2.3"
|
||||||
@ -28,6 +29,7 @@ isort = "^4.3.21"
|
|||||||
toml = "^0.10.2"
|
toml = "^0.10.2"
|
||||||
flake8 = "^3.9.1"
|
flake8 = "^3.9.1"
|
||||||
autoimport = "^0.7.0"
|
autoimport = "^0.7.0"
|
||||||
|
construct-typing = "^0.4.2"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
jubeatools = 'jubeatools.cli.cli:convert'
|
jubeatools = 'jubeatools.cli.cli:convert'
|
||||||
|
Loading…
Reference in New Issue
Block a user