1
0
mirror of synced 2024-09-23 18:58:21 +02:00

add yubiosi 2.0

This commit is contained in:
Stepland 2024-07-13 21:44:58 +02:00
parent f66df3656f
commit 7c3c587a20
11 changed files with 502 additions and 80 deletions

View File

@ -35,3 +35,6 @@ Again, more details in [the documentation](https://jubeatools.readthedocs.io)
| jubeat (arcade) | .eve | ✔️ | ✔️ |
| jubeat plus | .jbsq | ✔️ | ✔️ |
| malody | .mc (Pad Mode) | ✔️ | ✔️ |
| yubiosi | v1.0 | ✔️ | ✔️ |
| | v1.5 | ✔️ | ✔️ |
| | v2.0 | ✔️ | ✔️ |

View File

@ -1,12 +1,10 @@
import string
from functools import singledispatch
from itertools import count
from pathlib import Path
from typing import AbstractSet, Any, Dict, Iterator, Optional, TypedDict
from jubeatools.formats.filetypes import ChartFile, JubeatFile, SongFile
from jubeatools.formats.typing import ChartFileDumper, Dumper
from jubeatools.song import Difficulty, Song
from jubeatools.song import Chart, Difficulty, Song
from jubeatools.utils import none_or
DIFFICULTY_NUMBER: Dict[str, int] = {
@ -35,8 +33,11 @@ def make_dumper_from_chart_file_dumper(
name_format = FileNameFormat(file_name_template, suggestion=path)
files = internal_dumper(song, **kwargs)
for chartfile in files:
filepath = name_format.available_filename_for(
chartfile, already_chosen=res.keys()
filepath = name_format.available_chart_filename(
chartfile.song,
chartfile.difficulty,
chartfile.chart,
already_chosen=res.keys(),
)
res[filepath] = chartfile.contents
@ -67,10 +68,20 @@ class FileNameFormat:
self.name_format = f"{file_path.stem}{{dedup}}{file_path.suffix}"
def available_filename_for(
self, file: JubeatFile, already_chosen: Optional[AbstractSet[Path]] = None
def available_chart_filename(
self,
song: Song,
difficulty: str,
chart: Chart,
already_chosen: Optional[AbstractSet[Path]] = None,
) -> Path:
fixed_params = extract_format_params(file)
fixed_params = extract_chart_format_params(song, difficulty, chart)
return next(self.iter_possible_paths(fixed_params, already_chosen))
def available_song_filename(
self, song: Song, already_chosen: Optional[AbstractSet[Path]] = None
) -> Path:
fixed_params = extract_song_format_params(song)
return next(self.iter_possible_paths(fixed_params, already_chosen))
def iter_possible_paths(
@ -97,23 +108,18 @@ class FileNameFormat:
yield self.parent / filename
@singledispatch
def extract_format_params(file: JubeatFile) -> FormatParameters:
raise ValueError(f"Unexpected argument : {file}")
def extract_song_format_params(song: Song) -> FormatParameters:
return FormatParameters(title=none_or(slugify, song.metadata.title) or "")
@extract_format_params.register
def extract_song_format_params(songfile: SongFile) -> FormatParameters:
return FormatParameters(title=none_or(slugify, songfile.song.metadata.title) or "")
@extract_format_params.register
def extract_chart_format_params(chartfile: ChartFile) -> FormatParameters:
def extract_chart_format_params(
song: Song, difficulty: str, chart: Chart
) -> FormatParameters:
return FormatParameters(
title=none_or(slugify, chartfile.song.metadata.title) or "",
difficulty=slugify(chartfile.difficulty),
difficulty_index=str(DIFFICULTY_INDEX.get(chartfile.difficulty, 2)),
difficulty_number=str(DIFFICULTY_NUMBER.get(chartfile.difficulty, 3)),
title=none_or(slugify, song.metadata.title) or "",
difficulty=slugify(difficulty),
difficulty_index=str(DIFFICULTY_INDEX.get(difficulty, 2)),
difficulty_number=str(DIFFICULTY_NUMBER.get(difficulty, 3)),
)

View File

@ -16,3 +16,4 @@ class Format(str, Enum):
MEMO_2 = "memo2"
YUBIOSI_1_0 = "yubiosi:v1.0"
YUBIOSI_1_5 = "yubiosi:v1.5"
YUBIOSI_2_0 = "yubiosi:v2.0"

View File

@ -20,6 +20,7 @@ LOADERS: Dict[Format, Loader] = {
Format.MEMO_2: jubeat_analyser.load_memo2,
Format.YUBIOSI_1_0: yubiosi.load_yubiosi_1_0,
Format.YUBIOSI_1_5: yubiosi.load_yubiosi_1_5,
Format.YUBIOSI_2_0: yubiosi.load_yubiosi_2_0,
}
#: Maps each Format enum member to its associated dumper
@ -38,4 +39,5 @@ DUMPERS: Dict[Format, Dumper] = {
Format.MEMO_2: jubeat_analyser.dump_memo2,
Format.YUBIOSI_1_0: yubiosi.dump_yubiosi_1_0,
Format.YUBIOSI_1_5: yubiosi.dump_yubiosi_1_5,
Format.YUBIOSI_2_0: yubiosi.dump_yubiosi_2_0,
}

View File

@ -41,8 +41,8 @@ def make_memon_folder_loader(memon_loader: Callable[[Any], jbt.Song]) -> Loader:
def make_memon_dumper(internal_dumper: SongFileDumper) -> Dumper:
def dump(song: jbt.Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]:
name_format = FileNameFormat(Path("{title}.memon"), suggestion=path)
filepath = name_format.available_song_filename(song)
songfile = internal_dumper(song, **kwargs)
filepath = name_format.available_filename_for(songfile)
return {filepath: songfile.contents}
return dump

View File

@ -1,14 +1,13 @@
"""
指押 (yubiosi) is a now-defunct jubeat simulator for Android
yubiosi (指押) is a now-defunct jubeat simulator for Android
It supports 3x3 and 4x4 charts.
It notably also supports 3x3 charts although jubeatools only supports
converting to and from 4x4 charts
jubeatools only supports converting to and from 4x4 charts
The three versions of the format it uses are documented on a dedicated wiki :
The three versions of the format are documented on a dedicated wiki :
https://w.atwiki.jp/yubiosi2/
"""
from .dump import dump_yubiosi_1_0, dump_yubiosi_1_5
from .load import load_yubiosi_1_0, load_yubiosi_1_5
from .dump import dump_yubiosi_1_0, dump_yubiosi_1_5, dump_yubiosi_2_0
from .load import load_yubiosi_1_0, load_yubiosi_1_5, load_yubiosi_2_0

View File

@ -1,13 +1,17 @@
from fractions import Fraction
from math import ceil
from pathlib import Path
from typing import Iterator, List
from typing import Any, Iterator, List, Sequence, Union
from uuid import uuid4
from jubeatools import song as jbt
from jubeatools.formats.dump_tools import make_dumper_from_chart_file_dumper
from jubeatools.formats.dump_tools import (
FileNameFormat,
make_dumper_from_chart_file_dumper,
)
from jubeatools.formats.filetypes import ChartFile
from jubeatools.formats.timemap import TimeMap
from jubeatools.info import __repository_url__, __version__
from jubeatools.utils import clamp, fraction_to_decimal
from .tools import INDEX_TO_YUBIOSI_1_0
@ -98,7 +102,11 @@ def iter_header_lines_1_5(
tags = compute_header_tags_1_5(metadata, chart, timing)
for key in TAG_ORDER_1_5:
if key in tags:
yield f"#{key}:{tags[key]}"
yield dump_tag(key, tags[key])
def dump_tag(key: str, value: str) -> str:
return f"#{key}:{value}"
def compute_header_tags_1_5(
@ -148,3 +156,127 @@ def dump_note_1_5(note: jbt.TapNote) -> str:
time = int(note.time * 4)
position = note.position.index + 1
return f"{time},{position}"
def dump_yubiosi_2_0(song: jbt.Song, path: Path, **kwargs: dict) -> dict[Path, bytes]:
info_file_name_format = FileNameFormat(Path("{title}.ybi"), suggestion=path)
ybi_path = info_file_name_format.available_song_filename(song)
taken_filenames = {ybi_path}
edits = sorted(song.charts.keys() - set(d.value for d in jbt.Difficulty))
if len(edits) > 7:
raise ValueError(
f"This song has too many edit difficulties ({len(edits)}), yubiosi "
"2.0 only supports 7 per song."
)
chart_paths = {}
chart_path_suggestion = path.with_suffix(".ybh") if not path.is_dir() else path
for dif, chart in song.charts.items():
chart_file_name_format = FileNameFormat(
Path("{title}[{difficulty}].ybh"), suggestion=chart_path_suggestion
)
chart_filename = chart_file_name_format.available_chart_filename(
song, dif, chart, already_chosen=taken_filenames
)
taken_filenames.add(chart_filename)
chart_paths[dif] = chart_filename
files = {ybi_path: dump_yubiosi_2_0_info_file(song, edits, chart_paths)}
for dif, chart in song.charts.items():
chart_path = chart_paths[dif]
files[chart_path] = dump_yubiosi_2_0_chart_file(chart.notes)
return files
def dump_yubiosi_2_0_info_file(
song: jbt.Song, edits: list[str], chart_filenames: dict[str, Path]
) -> bytes:
song.minimize_timings()
if len(song.charts) > 1 and any(c.timing is not None for c in song.charts.values()):
raise ValueError("Yubiosi 2.0 does not support per-chart timing")
if len(song.charts) == 1:
timing = next(iter(song.charts.values())).timing or song.common_timing
else:
timing = song.common_timing
if timing is None:
raise ValueError("No timing info")
lines = [
"//Yubiosi 2.0",
f"// Converted using jubeatools {__version__}",
f"// {__repository_url__}",
]
lines.extend(iter_header_lines_2_0(song, timing))
diffs_order = list(j.value for j in jbt.Difficulty) + edits
for i, dif in enumerate(diffs_order, start=1):
if (chart := song.charts.get(dif)) is not None:
filename = chart_filenames[dif]
lines += dump_chart_section(chart, i, filename, timing.beat_zero_offset)
return "\n".join(lines).encode("utf-16")
TAG_ORDER_2_0 = ["TITLE_NAME", "ARTIST", "BPMS", "AUDIO_FILE", "JACKET", "BGM_START"]
def iter_header_lines_2_0(song: jbt.Song, timing: jbt.Timing) -> Iterator[str]:
tags = compute_header_tags_2_0(song, timing)
for key in TAG_ORDER_2_0:
if key in tags:
yield dump_tag(key, tags[key])
def compute_header_tags_2_0(song: jbt.Song, timing: jbt.Timing) -> dict[str, str]:
tags: dict[str, Any] = {}
if song.metadata.title is not None:
tags["TITLE_NAME"] = song.metadata.title
if song.metadata.artist is not None:
tags["ARTIST"] = song.metadata.artist
timemap = TimeMap.from_timing(timing)
tags["BPMS"] = dump_bpms(timemap, Fraction(timing.beat_zero_offset))
if song.metadata.audio is not None:
tags["AUDIO_FILE"] = song.metadata.audio
if song.metadata.cover is not None:
tags["JACKET"] = song.metadata.cover
if song.metadata.preview is not None:
tags["BGM_START"] = dump_bgm_start(song.metadata.preview.start)
return {k: str(v) for k, v in tags.items()}
def dump_bgm_start(start: jbt.SecondsTime) -> str:
return str(int(start * 1000))
def dump_chart_section(
chart: jbt.Chart, index: int, path: Path, offset: jbt.SecondsTime
) -> list[str]:
return [
f"[Notes:{index}]",
dump_tag("FILE", path.name),
dump_tag("LEVEL", str(clamp(int(chart.level or 0), 1, 99))),
dump_tag("OFFSET", str(int(offset * 1000))),
]
def dump_yubiosi_2_0_chart_file(
notes: Sequence[Union[jbt.TapNote, jbt.LongNote]]
) -> bytes:
if any(isinstance(n, jbt.LongNote) for n in notes):
raise ValueError("yubiosi 2.0 does not support long notes")
if any((n.time.denominator not in (1, 2, 4)) for n in notes):
raise ValueError("yubiosi 2.0 only supports 16th notes")
# redundant type check to placate mypy
note_lines = [dump_note_1_5(n) for n in notes if isinstance(n, jbt.TapNote)]
lines = ["//Yubiosi 2.0"] + note_lines
return "\n".join(lines).encode("utf-16")

View File

@ -1,29 +1,32 @@
import re
import warnings
from collections import defaultdict
from dataclasses import replace
from decimal import Decimal
from fractions import Fraction
from pathlib import Path
from typing import Any, Iterator
from typing import Any, Iterator, NamedTuple, Optional
from jubeatools import song
from jubeatools.formats.load_tools import FolderLoader, make_folder_loader
from jubeatools.formats.timemap import BPMAtSecond, TimeMap
from jubeatools.utils import fraction_to_decimal, none_or
from jubeatools.utils import none_or
from .tools import YUBIOSI_1_0_TO_INDEX
from .tools import INDEX_TO_DIF, YUBIOSI_1_0_TO_INDEX
def load_yubiosi_1_0(path: Path, **kwargs: Any) -> song.Song:
files = load_folder(path)
files = load_folder_1_x(path)
charts = [load_yubiosi_1_0_file(lines) for lines in files.values()]
return song.Song.from_monochart_instances(*charts)
def load_file(path: Path) -> list[str]:
def load_file_1_x(path: Path) -> list[str]:
with path.open(encoding="shift-jis-2004") as f:
return f.read().split("\n")
load_folder: FolderLoader[list[str]] = make_folder_loader("*.txt", load_file)
load_folder_1_x: FolderLoader[list[str]] = make_folder_loader("*.txt", load_file_1_x)
def load_yubiosi_1_0_file(lines: list[str]) -> song.Song:
@ -57,7 +60,7 @@ def load_yubiosi_1_0_file(lines: list[str]) -> song.Song:
chart = song.Chart(level=Decimal(0), timing=timing, notes=notes)
return song.Song(
metadata=song.Metadata(title=title),
charts={song.Difficulty.EXTREME.value: chart}
charts={song.Difficulty.EXTREME.value: chart},
)
@ -70,7 +73,7 @@ def load_1_0_note(time: int, position: int) -> song.TapNote:
def load_yubiosi_1_5(path: Path, **kwargs: Any) -> song.Song:
files = load_folder(path)
files = load_folder_1_x(path)
charts = [load_yubiosi_1_5_file(lines) for lines in files.values()]
return song.Song.from_monochart_instances(*charts)
@ -79,7 +82,7 @@ def load_yubiosi_1_5_file(lines: list[str]) -> song.Song:
try:
notes_marker = lines.index("[Notes]")
except ValueError:
raise ValueError("No [Notes] marker found in the file") from None
raise ValueError("No '[Notes]' line found in the file") from None
tag_lines = lines[:notes_marker]
note_lines = lines[notes_marker + 1 :]
@ -90,14 +93,18 @@ def load_yubiosi_1_5_file(lines: list[str]) -> song.Song:
raise ValueError(f"Unsupported PANEL_NUM : {panel_num}")
title = tags.get("TITLE_NAME")
timing = load_timing_data(tags)
timing = load_timing_data_1_5(tags)
notes = [load_1_5_note(line) for line in note_lines]
level = Decimal(tags.get("LEVEL", 0))
audio = tags.get("AUDIO_FILE")
return song.Song(
metadata=song.Metadata(title=title, audio=none_or(Path, audio)),
charts={song.Difficulty.EXTREME.value: song.Chart(level=level, timing=timing, notes=notes)},
charts={
song.Difficulty.EXTREME.value: song.Chart(
level=level, timing=timing, notes=notes
)
},
)
@ -110,27 +117,30 @@ def iter_tags(tag_lines: list[str]) -> Iterator[tuple[str, str]]:
yield match["key"], match["value"]
def load_timing_data(tags: dict[str, str]) -> song.Timing:
def load_timing_data_1_5(tags: dict[str, str]) -> song.Timing:
offset = load_offset(tags)
events = [replace(e, seconds=e.seconds + offset) for e in iter_bpm_events(tags)]
return TimeMap.from_seconds(events).convert_to_timing_info()
def load_offset(tags: dict[str, str]) -> Fraction:
offset_ms = int(tags.get("OFFSET", 0))
offset = Fraction(offset_ms, 1000)
return Fraction(offset_ms, 1000)
def iter_bpm_events(tags: dict[str, str]) -> Iterator[BPMAtSecond]:
if (raw_bpm_changes := tags.get("BPMS")) is not None:
events = list(iter_bpm_changes(raw_bpm_changes, offset))
return TimeMap.from_seconds(events).convert_to_timing_info()
yield from iter_bpms_tag(raw_bpm_changes)
elif (raw_bpm := tags.get("BPM")) is not None:
return song.Timing(
events=[song.BPMEvent(time=song.BeatsTime(0), BPM=Decimal(raw_bpm))],
beat_zero_offset=fraction_to_decimal(offset),
)
yield BPMAtSecond(Fraction(0), Fraction(Decimal(raw_bpm)))
else:
raise ValueError("The file is missing either a #BPM or a #BPMS tag")
def iter_bpm_changes(tag_value: str, offset: Fraction) -> Iterator[BPMAtSecond]:
def iter_bpms_tag(tag_value: str) -> Iterator[BPMAtSecond]:
for event in tag_value.split(","):
bpm, ms = event.split("/")
yield BPMAtSecond(
seconds=offset + Fraction(int(ms), 1000), BPM=Fraction(Decimal(bpm))
)
yield BPMAtSecond(seconds=Fraction(int(ms), 1000), BPM=Fraction(Decimal(bpm)))
def load_1_5_note(line: str) -> song.TapNote:
@ -139,3 +149,149 @@ def load_1_5_note(line: str) -> song.TapNote:
time=song.BeatsTime(int(raw_time), 4),
position=song.NotePosition.from_index(int(raw_position) - 1),
)
def load_yubiosi_2_0(path: Path, **kwargs: Any) -> song.Song:
ybi = find_ybi_file(path)
header, charts_headers = load_yubiosi_2_0_ybi_file(ybi)
metadata = load_yubiosi_2_0_metadata(header)
bpms = list(iter_bpm_events(header))
ignored_indicies = charts_headers.keys() - INDEX_TO_DIF.keys()
if ignored_indicies:
warnings.warn(
"This file contains [Notes:_] sections with invalid difficulty "
f"indicies : {ignored_indicies}. [Notes:_] sections with indicies "
"outside the 1-10 range are ignored."
)
charts = {}
for dif_index in charts_headers.keys() & INDEX_TO_DIF.keys():
tags = charts_headers[dif_index]
dif = INDEX_TO_DIF[dif_index]
chart = load_yubiosi_2_0_chart(tags, bpms, ybi)
charts[dif] = chart
return song.Song(metadata=metadata, charts=charts)
def find_ybi_file(path: Path) -> Path:
if path.is_file():
if path.suffix != ".ybi":
warnings.warn(
"The input file does not have the .ybi extension. Make sure "
"you gave the path to the metadata file and not the path to "
"one of the .ybh charts by mistake."
)
return path
elif path.is_dir():
ybis = list(path.glob("*.ybi"))
ybis_count = len(ybis)
if ybis_count > 1:
raise ValueError(
"Found multiple .ybi files in the given folder. The given path "
"should either be a file, or a folder containing only one file "
"with .ybi extension"
)
elif ybis_count == 0:
raise ValueError(
"No .ybi files found in the given folder. The given path "
"should either be a file, or a folder containing only one file "
"with .ybi extension"
)
else:
return ybis[0]
else:
raise ValueError("The source path is neither a file nor a folder")
def load_yubiosi_2_0_ybi_file(
ybi: Path,
) -> tuple[dict[str, str], dict[int, dict[str, str]]]:
lines = ybi.read_text(encoding="utf-16").split("\n")
if not lines:
raise ValueError("This file is empty")
warn_for_missing_version_comment(lines)
chart_headers: defaultdict[int, dict[str, str]] = defaultdict(dict)
file_header: dict[str, str] = {}
for dif_index, key, value in iter_section_and_tags(lines):
if dif_index is None:
file_header[key] = value
else:
chart_headers[dif_index][key] = value
return file_header, dict(chart_headers)
def warn_for_missing_version_comment(lines: list[str]) -> None:
if lines[0] != "//Yubiosi 2.0":
warnings.warn(
"Unexpected text on the first line of the .ybi file. "
"The first line should be '//Yubiosi 2.0'"
)
class TagWithSection(NamedTuple):
section: Optional[int]
key: str
value: str
SECTION_REGEX = re.compile(r"\[Notes:(\d+)\]")
def iter_section_and_tags(lines: list[str]) -> Iterator[TagWithSection]:
section = None
for i, line in enumerate(lines, start=1):
if line.startswith("//"):
continue
elif match := SECTION_REGEX.fullmatch(line):
section = int(match[1])
elif match := TAG_REGEX.fullmatch(line):
yield TagWithSection(
section=section, key=match["key"], value=match["value"]
)
elif line.strip():
warnings.warn(f"Unexpected text on line {i}")
def load_preview_start(val: str) -> Decimal:
return Decimal(val) / 1000
def load_yubiosi_2_0_metadata(header: dict[str, str]) -> song.Metadata:
return song.Metadata(
title=header.get("TITLE_NAME"),
artist=header.get("ARTIST"),
audio=none_or(Path, header.get("AUDIO_FILE")),
cover=none_or(Path, header.get("JACKET")),
preview=none_or(
lambda t: song.Preview(start=load_preview_start(t), length=Decimal(15)),
header.get("BGM_START"),
),
)
def load_yubiosi_2_0_chart(
tags: dict[str, str], bpms: list[BPMAtSecond], ybi: Path
) -> song.Chart:
file = ybi.parent / Path(tags["FILE"])
lines = file.read_text("utf-16").split("\n")
warn_for_missing_version_comment(lines)
offset = load_offset(tags)
timing = TimeMap.from_seconds(
[replace(e, seconds=e.seconds + offset) for e in bpms]
).convert_to_timing_info()
level = Decimal(tags.get("LEVEL", 0))
notes = list(iter_yubiosi_2_0_notes(lines))
return song.Chart(level=level, timing=timing, notes=notes)
def iter_yubiosi_2_0_notes(lines: list[str]) -> Iterator[song.TapNote]:
for i, line in enumerate(lines, start=1):
if line.startswith("//"):
continue
else:
try:
yield load_1_5_note(line)
except Exception:
warnings.warn(f"Couldn't parse line {i} as a note ... ignoring it")

View File

@ -7,6 +7,8 @@ from hypothesis import strategies as st
from jubeatools import song
from jubeatools.formats import Format
from jubeatools.formats.yubiosi.dump import dump_bgm_start
from jubeatools.formats.yubiosi.load import load_preview_start
from jubeatools.testutils import strategies as jbst
from jubeatools.testutils.test_patterns import dump_and_load_then_compare
@ -64,37 +66,39 @@ def metadata_1_5(draw: st.DrawFn) -> song.Metadata:
audio=Path(draw(jbst.shift_jis_safe_text())),
)
@st.composite
def safe_bpms(draw: st.DrawFn) -> Decimal:
"""Draw from a selection of bpms with a finite decimal expansion and whole
number of ms per 1/4 note"""
val = draw(st.sampled_from([
"75",
"78.125",
"93.75",
"100",
"117.1875",
"120",
"125",
"150",
"156.25",
"187.5",
"200",
"234.375",
"250"
]))
val = draw(
st.sampled_from(
[
"75",
"78.125",
"93.75",
"100",
"117.1875",
"120",
"125",
"150",
"156.25",
"187.5",
"200",
"234.375",
"250",
]
)
)
return Decimal(val)
@st.composite
def yubiosi_1_5_song(draw: st.DrawFn) -> song.Song:
"""Yubiosi 1.5 files are a bit less restricted :
- one chart
- integer level in the 1-10 range
- no dif info
- only 16th notes
- only tap notes
- bpm changes allowed
- title and audio path
- audio path
"""
return draw(
jbst.song(
@ -104,10 +108,12 @@ def yubiosi_1_5_song(draw: st.DrawFn) -> song.Song:
chart_strat=jbst.chart(
level_strat=st.integers(min_value=1, max_value=10),
timing_strat=jbst.timing_info(
with_bpm_changes=True,
with_bpm_changes=True,
bpm_strat=safe_bpms(),
beat_zero_offset_strat=st.decimals(min_value=0, max_value=20, places=2),
time_strat=just_16ths(min_section=1)
beat_zero_offset_strat=st.decimals(
min_value=0, max_value=20, places=2
),
time_strat=just_16ths(min_section=1),
),
notes_strat=jbst.notes(
note_strat=jbst.tap_note(time_strat=just_16ths()),
@ -127,3 +133,97 @@ def test_that_full_chart_roundtrips_1_5(s: song.Song) -> None:
bytes_decoder=lambda b: b.decode("shift-jis-2004"),
test_guess_format=False,
)
@st.composite
def yubiosi_diffs(draw: st.DrawFn) -> set[str]:
normal_diffs = draw(
st.sets(
st.sampled_from(list(d.value for d in song.Difficulty)),
min_size=0,
max_size=3,
)
)
if not normal_diffs:
min_edits = 1
else:
min_edits = 0
edits = draw(st.integers(min_value=min_edits, max_value=7))
return normal_diffs | {f"EDIT-{i+1}" for i in range(edits)}
seconds_time = partial(
st.decimals,
min_value=0,
max_value=10**25,
allow_nan=False,
allow_infinity=False,
places=2
)
@st.composite
def metadata_2_0(draw: st.DrawFn) -> song.Metadata:
return song.Metadata(
title=draw(jbst.metadata_text()),
artist=draw(jbst.metadata_text()),
audio=Path(draw(jbst.metadata_path())),
cover=Path(draw(jbst.metadata_path())),
preview=song.Preview(
start=draw(seconds_time()),
length=Decimal(15),
),
preview_file=None,
)
@st.composite
def yubiosi_2_0_song(draw: st.DrawFn) -> song.Song:
"""Yubiosi 2.0 files have more stuff :
- utf-16 support
- artist tag
- jacket tag
- audio tag
- preview start tag
- sets of charts, with the usual 3 difficulties + 7 optional edits
- integer levels extented to the 1-99 range
"""
return draw(
jbst.song(
diffs_strat=yubiosi_diffs(),
common_timing_strat=jbst.timing_info(
with_bpm_changes=True,
bpm_strat=safe_bpms(),
beat_zero_offset_strat=st.decimals(min_value=0, max_value=20, places=2),
time_strat=just_16ths(min_section=1),
),
common_hakus_strat=st.none(),
chart_strat=jbst.chart(
level_strat=st.integers(min_value=1, max_value=99),
timing_strat=st.none(),
notes_strat=jbst.notes(
note_strat=jbst.tap_note(time_strat=just_16ths()),
beat_time_strat=just_16ths(),
),
),
metadata_strat=metadata_2_0(),
)
)
@given(seconds_time())
def test_that_bgm_start_roundtrips(expected: Decimal) -> None:
text = dump_bgm_start(expected)
actual = load_preview_start(text)
assert actual == expected
@given(yubiosi_2_0_song())
def test_that_full_chart_roundtrips_2_0(s: song.Song) -> None:
dump_and_load_then_compare(
Format.YUBIOSI_2_0,
s,
bytes_decoder=lambda b: b.decode("utf-16"),
test_guess_format=False,
)

View File

@ -1,3 +1,19 @@
from jubeatools import song
from jubeatools.utils import reverse_dict
INDEX_TO_YUBIOSI_1_0 = {n: 2**n for n in range(16)}
YUBIOSI_1_0_TO_INDEX = {v: k for k, v in INDEX_TO_YUBIOSI_1_0.items()}
YUBIOSI_1_0_TO_INDEX = reverse_dict(INDEX_TO_YUBIOSI_1_0)
INDEX_TO_DIF = {
1: song.Difficulty.BASIC.value,
2: song.Difficulty.ADVANCED.value,
3: song.Difficulty.EXTREME.value,
4: "EDIT-1",
5: "EDIT-2",
6: "EDIT-3",
7: "EDIT-4",
8: "EDIT-5",
9: "EDIT-6",
10: "EDIT-7",
}

View File

@ -76,6 +76,13 @@ def group_by(elements: Iterable[V], key: Callable[[V], K]) -> Dict[K, List[V]]:
return res
V2 = TypeVar("V2", bound=Hashable)
def reverse_dict(d: dict[K, V2]) -> dict[V2, K]:
return {v: k for k, v in d.items()}
def mixed_number_format(f: Fraction) -> str:
"""Formats fractions the following way :