diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f1761..d68b989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # v1.4.0 ## Added -- Jubeatools can now handle HAKUs, in the following formats : +- Jubeatools can now handle HAKUs in the following formats : - [memon:v1.0.0] + - [eve] + - [jbsq] - [memon] - 🎉 inital support for v1.0.0 ! - `--merge` option allows for several memon files to be merged when diff --git a/jubeatools/formats/konami/dump_tools.py b/jubeatools/formats/konami/dump_tools.py index c44097e..37a7a9e 100644 --- a/jubeatools/formats/konami/dump_tools.py +++ b/jubeatools/formats/konami/dump_tools.py @@ -1,7 +1,7 @@ import math from fractions import Fraction from functools import singledispatch -from typing import List +from typing import List, Optional, Set from more_itertools import numeric_range @@ -11,10 +11,12 @@ from jubeatools.formats.timemap import TimeMap from .commons import AnyNote, Command, Event, bpm_to_value, ticks_at_beat -def make_events_from_chart(notes: List[AnyNote], timing: song.Timing) -> List[Event]: +def make_events_from_chart( + notes: List[AnyNote], timing: song.Timing, hakus: Optional[Set[song.BeatsTime]] +) -> List[Event]: time_map = TimeMap.from_timing(timing) note_events = make_note_events(notes, time_map) - timing_events = make_timing_events(notes, timing, time_map) + timing_events = make_timing_events(notes, timing, hakus, time_map) return sorted(note_events + timing_events) @@ -38,14 +40,21 @@ def make_long_note_event(note: song.LongNote, time_map: TimeMap) -> Event: def make_timing_events( - notes: List[AnyNote], timing: song.Timing, time_map: TimeMap + notes: List[AnyNote], + timing: song.Timing, + hakus: Optional[Set[song.BeatsTime]], + 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] + if hakus is not None: + haku_events = dump_hakus(hakus, time_map) + else: + haku_events = make_regular_hakus(end_beat, time_map) + + return bpm_events + measure_events + haku_events + [end_event] def make_bpm_event(bpm_change: song.BPMEvent, time_map: TimeMap) -> Event: @@ -94,14 +103,18 @@ def make_measure_event(beat: song.BeatsTime, time_map: TimeMap) -> Event: return Event(time=ticks, command=Command.MEASURE, value=0) -def make_beat_events(end_beat: song.BeatsTime, time_map: TimeMap) -> List[Event]: +def dump_hakus(hakus: Set[song.BeatsTime], time_map: TimeMap) -> List[Event]: + return [make_haku_event(beat, time_map) for beat in sorted(hakus)] + + +def make_regular_hakus(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] + return [make_haku_event(beat, time_map) for beat in beats] -def make_beat_event(beat: song.BeatsTime, time_map: TimeMap) -> Event: +def make_haku_event(beat: song.BeatsTime, time_map: TimeMap) -> Event: ticks = ticks_at_beat(beat, time_map) return Event(time=ticks, command=Command.HAKU, value=0) diff --git a/jubeatools/formats/konami/eve/dump.py b/jubeatools/formats/konami/eve/dump.py index 7f7aca3..bf12c64 100644 --- a/jubeatools/formats/konami/eve/dump.py +++ b/jubeatools/formats/konami/eve/dump.py @@ -10,8 +10,8 @@ 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_applicable_timing(): - events = make_events_from_chart(chart.notes, timing) + for dif, chart, timing, hakus in song.iter_charts(): + events = make_events_from_chart(chart.notes, timing, hakus) chart_text = "\n".join(e.dump() for e in events) chart_bytes = chart_text.encode("ascii") res.append(ChartFile(chart_bytes, song, dif, chart)) diff --git a/jubeatools/formats/konami/jbsq/dump.py b/jubeatools/formats/konami/jbsq/dump.py index c83a678..9210d1c 100644 --- a/jubeatools/formats/konami/jbsq/dump.py +++ b/jubeatools/formats/konami/jbsq/dump.py @@ -15,8 +15,8 @@ from . import construct def _dump_jbsq(song: song.Song, **kwargs: dict) -> List[ChartFile]: res = [] - for dif, chart, timing in song.iter_charts_with_applicable_timing(): - events = make_events_from_chart(chart.notes, timing) + for dif, chart, timing, hakus in song.iter_charts(): + events = make_events_from_chart(chart.notes, timing, hakus) jbsq_chart = make_jbsq_chart(events, chart.notes) chart_bytes = construct.jbsq.build(jbsq_chart) res.append(ChartFile(chart_bytes, song, dif, chart)) diff --git a/jubeatools/formats/konami/load_tools.py b/jubeatools/formats/konami/load_tools.py index 415acb8..9ea50cf 100644 --- a/jubeatools/formats/konami/load_tools.py +++ b/jubeatools/formats/konami/load_tools.py @@ -1,5 +1,7 @@ from decimal import Decimal -from typing import Iterable, List +from typing import Iterable, List, Optional, Set + +from more_itertools import numeric_range from jubeatools import song from jubeatools.formats.load_tools import round_beats @@ -36,17 +38,22 @@ def make_chart_from_events(events: Iterable[Event], beat_snap: int = 240) -> son ] 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) + end_tick = events_by_command[Command.END].pop().time + hakus = make_hakus( + [e.time for e in events_by_command[Command.HAKU]], + end_tick, + time_map, + beat_snap, + ) + return song.Chart(level=Decimal(0), timing=timing, notes=all_notes, hakus=hakus) 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) + time = beats_at_tick(ticks, time_map, beat_snap) position = song.NotePosition.from_index(value) - return song.TapNote(time=beats, position=position) + return song.TapNote(time=time, position=position) def make_long_note( @@ -67,3 +74,52 @@ def make_long_note( return song.LongNote( time=beats, position=position, duration=beats_duration, tail_tip=tail_pos ) + + +def make_hakus( + hakus: List[int], end: int, time_map: TimeMap, beat_snap: int +) -> Optional[Set[song.BeatsTime]]: + """Try to detect if the haku pattern is regular, in which case return None, + otherwise return the parsed hakus""" + roughly_rounded_hakus = make_raw_hakus(hakus, time_map, beat_snap=4) + rough_end = beats_at_tick(end, time_map, beat_snap=4) + if follows_regular_haku_pattern(roughly_rounded_hakus, rough_end): + return None + else: + return make_raw_hakus(hakus, time_map, beat_snap) + + +def make_raw_hakus( + hakus: List[int], time_map: TimeMap, beat_snap: int +) -> Set[song.BeatsTime]: + return set(beats_at_tick(haku, time_map, beat_snap) for haku in hakus) + + +def follows_regular_haku_pattern( + hakus: Set[song.BeatsTime], end_command: song.BeatsTime +) -> bool: + """Regular hakus extend at least till the END command in a 4/4 rhythm""" + if len(hakus) == 0: + return False + + start = min(hakus) + if (start % 1) != 0: + return False + + haku_end = max(hakus) + if (haku_end % 1) != 0: + return False + + if haku_end < end_command: + return False + + stop = haku_end + song.BeatsTime(1, 2) + step = song.BeatsTime(1) + regular = numeric_range(start, stop, step) + return sorted(hakus) == list(regular) + + +def beats_at_tick(tick: int, time_map: TimeMap, beat_snap: int) -> song.BeatsTime: + seconds = ticks_to_seconds(tick) + raw_beats = time_map.beats_at(seconds) + return round_beats(raw_beats, beat_snap) diff --git a/jubeatools/formats/konami/testutils.py b/jubeatools/formats/konami/testutils.py index 5f8d71f..e4fa4d9 100644 --- a/jubeatools/formats/konami/testutils.py +++ b/jubeatools/formats/konami/testutils.py @@ -12,7 +12,7 @@ simple_beat_strat = jbst.beat_time( @st.composite def eve_compatible_song(draw: st.DrawFn) -> song.Song: - """eve only keeps notes, timing info and difficulty, + """eve only keeps notes, hakus, timing info and difficulty, the precision you can get out of it is also severly limited""" diff = draw(st.sampled_from(list(song.Difficulty))) chart = draw( @@ -42,6 +42,7 @@ def eve_compatible_song(draw: st.DrawFn) -> song.Song: beat_time_strat=simple_beat_strat, ), level_strat=st.just(Decimal(0)), + hakus_strat=st.one_of(st.none(), st.sets(simple_beat_strat)), ) ) return song.Song( diff --git a/jubeatools/formats/memon/v1/dump.py b/jubeatools/formats/memon/v1/dump.py index 1778586..4f87c8e 100644 --- a/jubeatools/formats/memon/v1/dump.py +++ b/jubeatools/formats/memon/v1/dump.py @@ -12,9 +12,7 @@ from ..tools import make_memon_dumper from . import schema as memon -def _dump_memon_1_0_0( - song: jbt.Song, use_fractions: bool = False, **kwargs: Any -) -> SongFile: +def _dump_memon_1_0_0(song: jbt.Song, **kwargs: Any) -> SongFile: metadata = dump_metadata(song.metadata) common_timing = dump_file_timing(song) charts = { diff --git a/jubeatools/song.py b/jubeatools/song.py index acb0222..ce737ea 100644 --- a/jubeatools/song.py +++ b/jubeatools/song.py @@ -349,3 +349,15 @@ class Song: f"Neither song nor {dif} chart have any timing information" ) yield dif, chart, timing + + def iter_charts( + self, + ) -> Iterator[Tuple[str, Chart, Timing, Optional[Set[BeatsTime]]]]: + 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" + ) + hakus = chart.hakus if chart.hakus is not None else self.common_hakus + yield dif, chart, timing, hakus