add yubiosi 2.0
This commit is contained in:
parent
f66df3656f
commit
7c3c587a20
@ -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 | ✔️ | ✔️ |
|
||||
|
@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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")
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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",
|
||||
}
|
||||
|
@ -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 :
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user