pre-debug #memo load and dump !
This commit is contained in:
parent
8abdf8edd6
commit
0fc63e08aa
214
jubeatools/formats/jubeat_analyser/dump_tools.py
Normal file
214
jubeatools/formats/jubeat_analyser/dump_tools.py
Normal file
@ -0,0 +1,214 @@
|
||||
"""Collection of tools realted to dumping to jubeat analyser formats"""
|
||||
from abc import ABC, abstractmethod
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from itertools import chain
|
||||
from typing import Dict, Optional, List, Union, Iterator, Callable, Mapping
|
||||
|
||||
from more_itertools import collapse, intersperse, mark_ends, windowed
|
||||
from sortedcontainers import SortedDict, SortedKeyList
|
||||
|
||||
from jubeatools.song import BeatsTime, TapNote, LongNote, NotePosition, Chart, Timing, Metadata
|
||||
|
||||
from .command import dump_command
|
||||
from .symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
|
||||
|
||||
COMMAND_ORDER = [
|
||||
"b",
|
||||
"t",
|
||||
"m",
|
||||
"o",
|
||||
"r",
|
||||
"title",
|
||||
"artist",
|
||||
"lev",
|
||||
"dif",
|
||||
"jacket",
|
||||
"prevpos",
|
||||
"holdbyarrow",
|
||||
"circlefree"
|
||||
]
|
||||
|
||||
BEATS_TIME_TO_SYMBOL = {
|
||||
BeatsTime(1, 4) * index: symbol for index, symbol in enumerate(NOTE_SYMBOLS)
|
||||
}
|
||||
|
||||
BEATS_TIME_TO_CIRCLE_FREE = {
|
||||
BeatsTime(1, 4) * index: symbol for index, symbol in enumerate(CIRCLE_FREE_SYMBOLS)
|
||||
}
|
||||
|
||||
NOTE_TO_CIRCLE_FREE_SYMBOL = dict(zip(NOTE_SYMBOLS, CIRCLE_FREE_SYMBOLS))
|
||||
|
||||
DIRECTION_TO_ARROW = {
|
||||
NotePosition(-1, 0): ">", # U+FF1E : FULLWIDTH GREATER-THAN SIGN
|
||||
NotePosition(1, 0): "<", # U+FF1C : FULLWIDTH LESS-THAN SIGN
|
||||
NotePosition(0, -1): "∨", # U+2228 : LOGICAL OR
|
||||
NotePosition(0, 1): "∧", # U+2227 : LOGICAL AND
|
||||
}
|
||||
|
||||
DIRECTION_TO_LINE = {
|
||||
NotePosition(-1, 0): "―", # U+2015 : HORIZONTAL BAR
|
||||
NotePosition(1, 0): "―",
|
||||
NotePosition(0, -1): "|", # U+FF5C : FULLWIDTH VERTICAL LINE
|
||||
NotePosition(0, 1): "|",
|
||||
}
|
||||
|
||||
DIFFICULTIES = {"BSC": 1, "ADV": 2, "EXT": 3}
|
||||
|
||||
# I put a FUCKTON of extra characters just in case some insane chart uses
|
||||
# loads of unusual beat divisions
|
||||
DEFAULT_EXTRA_SYMBOLS = (
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
"あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん"
|
||||
"アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン"
|
||||
)
|
||||
|
||||
|
||||
def fraction_to_decimal(frac: Fraction):
|
||||
"Thanks stackoverflow ! https://stackoverflow.com/a/40468867/10768117"
|
||||
return frac.numerator / Decimal(frac.denominator)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LongNoteEnd:
|
||||
time: BeatsTime
|
||||
position: NotePosition
|
||||
|
||||
|
||||
class SortedDefaultDict(SortedDict):
|
||||
|
||||
"""Custom SortedDict that also acts as a defaultdict,
|
||||
passes the key to the value factory"""
|
||||
|
||||
def __init__(self, factory, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__factory__ = factory
|
||||
|
||||
def add_key(self, key):
|
||||
if key not in self:
|
||||
value = self.__factory__(key)
|
||||
self.__setitem__(key, value)
|
||||
|
||||
def __missing__(self, key):
|
||||
value = self.__factory__(key)
|
||||
self.__setitem__(key, value)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass
|
||||
class JubeatAnalyserDumpedSection(ABC):
|
||||
current_beat: BeatsTime
|
||||
length: Decimal = 4
|
||||
commands: Dict[str, Optional[str]] = field(default_factory=dict)
|
||||
symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict)
|
||||
symbols: Dict[BeatsTime, str] = field(default_factory=dict)
|
||||
notes: List[Union[TapNote, LongNote, LongNoteEnd]] = field(default_factory=list)
|
||||
|
||||
def render(self, circle_free: bool = False) -> str:
|
||||
blocs = []
|
||||
commands = list(self._dump_commands())
|
||||
if commands:
|
||||
blocs.append(commands)
|
||||
symbols = list(self._dump_symbol_definitions())
|
||||
if symbols:
|
||||
blocs.append(symbols)
|
||||
notes = list(self._dump_notes(circle_free))
|
||||
if notes:
|
||||
blocs.append(notes)
|
||||
return "\n".join(collapse([intersperse("", blocs), "--"]))
|
||||
|
||||
def _dump_commands(self) -> Iterator[str]:
|
||||
keys = chain(COMMAND_ORDER, self.commands.keys() - set(COMMAND_ORDER))
|
||||
for key in keys:
|
||||
try:
|
||||
value = self.commands[key]
|
||||
except KeyError:
|
||||
continue
|
||||
yield dump_command(key, value)
|
||||
|
||||
def _dump_symbol_definitions(self) -> Iterator[str]:
|
||||
for time, symbol in self.symbol_definitions.items():
|
||||
decimal_time = fraction_to_decimal(time)
|
||||
yield f"*{symbol}:{decimal_time:.6f}"
|
||||
|
||||
@abstractmethod
|
||||
def _dump_notes(self, circle_free: bool = False,) -> Iterator[str]:
|
||||
...
|
||||
|
||||
|
||||
def create_sections_from_chart(
|
||||
section_factory: Callable[[BeatsTime], JubeatAnalyserDumpedSection],
|
||||
chart: Chart,
|
||||
difficulty: str,
|
||||
timing: Timing,
|
||||
metadata: Metadata,
|
||||
circle_free: bool
|
||||
) -> Mapping[BeatsTime, JubeatAnalyserDumpedSection]:
|
||||
sections = SortedDefaultDict(section_factory)
|
||||
|
||||
timing_events = sorted(timing.events, key=lambda e: e.time)
|
||||
notes = SortedKeyList(set(chart.notes), key=lambda n: n.time)
|
||||
|
||||
for note in chart.notes:
|
||||
if isinstance(note, LongNote):
|
||||
notes.add(LongNoteEnd(note.time + note.duration, note.position))
|
||||
|
||||
all_events = SortedKeyList(timing_events + notes, key=lambda n: n.time)
|
||||
last_event = all_events[-1]
|
||||
last_measure = last_event.time // 4
|
||||
for i in range(last_measure + 1):
|
||||
beat = BeatsTime(4) * i
|
||||
sections.add_key(beat)
|
||||
|
||||
header = sections[0].commands
|
||||
header["o"] = int(timing.beat_zero_offset * 1000)
|
||||
header["lev"] = int(chart.level)
|
||||
header["dif"] = DIFFICULTIES.get(difficulty, 1)
|
||||
if metadata.audio:
|
||||
header["m"] = metadata.audio
|
||||
if metadata.title:
|
||||
header["title"] = metadata.title
|
||||
if metadata.artist:
|
||||
header["artist"] = metadata.artist
|
||||
if metadata.cover:
|
||||
header["jacket"] = metadata.cover
|
||||
if metadata.preview is not None:
|
||||
header["prevpos"] = int(metadata.preview.start * 1000)
|
||||
|
||||
if any(isinstance(note, LongNote) for note in chart.notes):
|
||||
header["holdbyarrow"] = 1
|
||||
|
||||
if circle_free:
|
||||
header["circlefree"] = 1
|
||||
|
||||
# Potentially create sub-sections for bpm changes
|
||||
for event in timing_events:
|
||||
sections[event.time].commands["t"] = event.BPM
|
||||
|
||||
# First, Set every single b=… value
|
||||
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
|
||||
if next_key is None:
|
||||
length = 4
|
||||
else:
|
||||
length = fraction_to_decimal(next_key - key)
|
||||
sections[key].commands["b"] = length
|
||||
sections[key].length = length
|
||||
|
||||
# Then, trim all the redundant b=…
|
||||
last_b = 4
|
||||
for section in sections.values():
|
||||
current_b = section.commands["b"]
|
||||
if current_b == last_b:
|
||||
del section.commands["b"]
|
||||
else:
|
||||
last_b = current_b
|
||||
|
||||
# Fill sections with notes
|
||||
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
|
||||
sections[key].notes = list(
|
||||
notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False))
|
||||
)
|
||||
|
||||
return sections
|
@ -1,4 +1,4 @@
|
||||
"""Collection of tools that are common to all the jubeat analyser formats"""
|
||||
"""Collection of parsing tools that are common to all the jubeat analyser formats"""
|
||||
import re
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
@ -1,2 +1,250 @@
|
||||
def dump_memo(song):
|
||||
from collections import ChainMap, defaultdict
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from functools import partial
|
||||
from io import StringIO
|
||||
from itertools import chain, zip_longest
|
||||
from math import ceil
|
||||
from typing import Dict, Iterator, List, Optional, Tuple, Union, Set
|
||||
|
||||
from more_itertools import chunked, collapse, intersperse, mark_ends, windowed
|
||||
from path import Path
|
||||
from sortedcontainers import SortedKeyList
|
||||
|
||||
from jubeatools import __version__
|
||||
from jubeatools.formats.filetypes import ChartFile, JubeatFile
|
||||
from jubeatools.song import (
|
||||
BeatsTime,
|
||||
Chart,
|
||||
LongNote,
|
||||
Metadata,
|
||||
NotePosition,
|
||||
Song,
|
||||
TapNote,
|
||||
Timing,
|
||||
)
|
||||
|
||||
from ..command import dump_command
|
||||
from ..dump_tools import (
|
||||
BEATS_TIME_TO_SYMBOL,
|
||||
COMMAND_ORDER,
|
||||
DEFAULT_EXTRA_SYMBOLS,
|
||||
DIFFICULTIES,
|
||||
DIRECTION_TO_ARROW,
|
||||
DIRECTION_TO_LINE,
|
||||
NOTE_TO_CIRCLE_FREE_SYMBOL,
|
||||
JubeatAnalyserDumpedSection,
|
||||
create_sections_from_chart,
|
||||
LongNoteEnd,
|
||||
SortedDefaultDict,
|
||||
fraction_to_decimal,
|
||||
)
|
||||
from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
|
||||
|
||||
|
||||
AnyNote = Union[TapNote, LongNote, LongNoteEnd]
|
||||
|
||||
EMPTY_BEAT_SYMBOL = "-" # U+0FF0D : FULLWIDTH HYPHEN-MINUS
|
||||
EMPTY_POSITION_SYMBOL = "□" # U+025A1 : WHITE SQUARE
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
positions: Dict[NotePosition, str] = field(default_factory=dict)
|
||||
bars: Dict[int, Dict[int, str]] = field(default_factory=dict)
|
||||
|
||||
def dump(self, length: Decimal) -> Iterator[str]:
|
||||
# Check that bars are contiguous
|
||||
for a, b in windowed(sorted(self.bars), 2):
|
||||
if b is not None and b-a != 1:
|
||||
raise ValueError("Frame has discontinuous bars")
|
||||
# Check all bars are in the same 4-bar group
|
||||
if self.bars.keys() != set(bar%4 for bar in self.bars):
|
||||
raise ValueError("Frame contains bars from different 4-bar groups")
|
||||
|
||||
for pos, bar in zip_longest(self.dump_positions(), self.dump_bars(length)):
|
||||
if bar is None:
|
||||
bar = ""
|
||||
yield f"{pos} {bar}"
|
||||
|
||||
|
||||
def dump_positions(self) -> Iterator[str]:
|
||||
for y in range(4):
|
||||
yield "".join(self.positions.get(NotePosition(x, y), EMPTY_POSITION_SYMBOL) for x in range(4))
|
||||
|
||||
def dump_bars(self, length: Decimal) -> Iterator[str]:
|
||||
all_bars = []
|
||||
for i in range(ceil(length * 4)):
|
||||
bar_index = i // 4
|
||||
time_index = i % 4
|
||||
symbol = self.bars.get(bar_index, {}).get(time_index, EMPTY_BEAT_SYMBOL)
|
||||
all_bars.append(symbol)
|
||||
|
||||
for i, bar in enumerate(chunked(all_bars, 4)):
|
||||
if i in self.bars:
|
||||
yield f"|{''.join(bar)}|"
|
||||
else:
|
||||
yield ""
|
||||
|
||||
|
||||
class MemoDumpedSection(JubeatAnalyserDumpedSection):
|
||||
def _dump_notes(self, circle_free: bool = False) -> Iterator[str]:
|
||||
notes_by_bar: Dict[int, List[AnyNote]] = defaultdict(list)
|
||||
bars: Dict[int, Dict[int, str]] = defaultdict(dict)
|
||||
chosen_symbols: Dict[BeatsTime, str] = {}
|
||||
symbols_iterator = iter(NOTE_SYMBOLS)
|
||||
for note in self.notes:
|
||||
time_in_section = note.time - self.current_beat
|
||||
bar_index = int(time_in_section)
|
||||
notes_by_bar[bar_index].append(note)
|
||||
if time_in_section % Fraction(1, 4) == 0:
|
||||
time_index = int(time_in_section * 4)
|
||||
if time_index not in bars[bar_index]:
|
||||
symbol = next(symbols_iterator)
|
||||
chosen_symbols[time_in_section] = symbol
|
||||
bars[bar_index][time_index] = symbol
|
||||
elif time_in_section not in self.symbols:
|
||||
raise ValueError(f"No symbol defined for time in section : {time_in_section}")
|
||||
|
||||
# Create frame by bar
|
||||
section_symbols = ChainMap(chosen_symbols, self.symbols)
|
||||
frames_by_bar: Dict[int, List[Frame]] = defaultdict(list)
|
||||
for bar_index in range(ceil(self.length)):
|
||||
bar = bars.get(bar_index, {})
|
||||
frame = Frame()
|
||||
frame.bars[bar_index] = bar
|
||||
for note in notes_by_bar[bar_index]:
|
||||
time_in_section = note.time - self.current_beat
|
||||
symbol = section_symbols[time_in_section]
|
||||
if isinstance(note, TapNote):
|
||||
if note.position in frame.positions:
|
||||
frames_by_bar[bar_index].append(frame)
|
||||
frame = Frame()
|
||||
frame.positions[note.position] = symbol
|
||||
elif isinstance(note, LongNote):
|
||||
needed_positions = set(note.positions_covered())
|
||||
if needed_positions & frame.positions.keys():
|
||||
frames_by_bar[bar_index].append(frame)
|
||||
frame = Frame()
|
||||
direction = note.tail_direction()
|
||||
arrow = DIRECTION_TO_ARROW[direction]
|
||||
line = DIRECTION_TO_LINE[direction]
|
||||
for is_first, is_last, pos in mark_ends(note.positions_covered()):
|
||||
if is_first:
|
||||
frame.positions[pos] = symbol
|
||||
elif is_last:
|
||||
frame.positions[pos] = arrow
|
||||
else:
|
||||
frame.positions[pos] = line
|
||||
elif isinstance(note, LongNoteEnd):
|
||||
if note.position in frame.positions:
|
||||
frames_by_bar[bar_index].append(frame)
|
||||
frame = Frame()
|
||||
if circle_free and symbol in NOTE_TO_CIRCLE_FREE_SYMBOL:
|
||||
symbol = NOTE_TO_CIRCLE_FREE_SYMBOL[symbol]
|
||||
frame.positions[note.position] = symbol
|
||||
frames_by_bar[bar_index].append(frame)
|
||||
|
||||
# Merge bar-specific frames is possible
|
||||
final_frames: List[Frame] = []
|
||||
for bar_index in range(ceil(self.length)):
|
||||
frames = frames_by_bar[bar_index]
|
||||
# Merge if :
|
||||
# - No split in current bar (only one frame)
|
||||
# - There is a previous frame
|
||||
# - The previous frame is not a split frame (it holds a bar)
|
||||
# - The previous and current bars are all in the same 4-bar group
|
||||
# - The note positions in the previous frame do not clash with the current frame
|
||||
if (
|
||||
len(frames) == 1 and
|
||||
final_frames and
|
||||
final_frames[-1].bars and
|
||||
max(final_frames[-1].bars.keys()) // 4 == min(frames[0].bars.keys()) // 4 and
|
||||
(not (final_frames[-1].positions.keys() & frames[0].positions.keys()))
|
||||
):
|
||||
final_frames[-1].bars.update(frames[0].bars)
|
||||
final_frames[-1].positions.update(frames[0].positions)
|
||||
else:
|
||||
final_frames.extend(frames)
|
||||
|
||||
dumped_frames = map(lambda f: f.dump(self.length), final_frames)
|
||||
yield from collapse(intersperse("", dumped_frames))
|
||||
|
||||
|
||||
|
||||
def _raise_if_unfit_for_memo(chart: Chart, timing: Timing, circle_free: bool = False):
|
||||
if len(timing.events) < 1:
|
||||
raise ValueError("No BPM found in file") from None
|
||||
|
||||
first_bpm = min(timing.events, key=lambda e: e.time)
|
||||
if first_bpm.time != 0:
|
||||
raise ValueError("First BPM event does not happen on beat zero")
|
||||
|
||||
if any(
|
||||
not note.tail_is_straight()
|
||||
for note in chart.notes
|
||||
if isinstance(note, LongNote)
|
||||
):
|
||||
raise ValueError(
|
||||
"Chart contains diagonal long notes, reprensenting these in"
|
||||
" memo format is not supported by jubeatools"
|
||||
)
|
||||
|
||||
|
||||
def _dump_memo_chart(
|
||||
difficulty: str,
|
||||
chart: Chart,
|
||||
metadata: Metadata,
|
||||
timing: Timing,
|
||||
circle_free: bool = False,
|
||||
) -> StringIO:
|
||||
|
||||
_raise_if_unfit_for_memo(chart, timing, circle_free)
|
||||
|
||||
sections = create_sections_from_chart(MemoDumpedSection, chart, difficulty, timing, metadata, circle_free)
|
||||
|
||||
# Jubeat Analyser format command
|
||||
sections[0].commands["memo"] = None
|
||||
|
||||
# Define extra symbols
|
||||
existing_symbols: Dict[BeatsTime, str] = {}
|
||||
extra_symbols = iter(DEFAULT_EXTRA_SYMBOLS)
|
||||
for section_start, section in sections.items():
|
||||
# intentionally not a copy : at the end of this loop every section
|
||||
# holds a reference to a dict containing every defined symbol
|
||||
section.symbols = existing_symbols
|
||||
for note in section.notes:
|
||||
time_in_section = note.time - section_start
|
||||
if time_in_section % Fraction(1, 4) != 0 and time_in_section not in existing_symbols:
|
||||
new_symbol = next(extra_symbols)
|
||||
section.symbol_definitions[time_in_section] = new_symbol
|
||||
existing_symbols[time_in_section] = new_symbol
|
||||
|
||||
# Actual output to file
|
||||
file = StringIO()
|
||||
file.write(f"// Converted using jubeatools {__version__}\n")
|
||||
file.write(f"// https://github.com/Stepland/jubeatools\n\n")
|
||||
for _, section in sections.items():
|
||||
file.write(section.render(circle_free) + "\n")
|
||||
|
||||
return file
|
||||
|
||||
|
||||
def _dump_memo_internal(song: Song, circle_free: bool = False) -> List[JubeatFile]:
|
||||
files = []
|
||||
for difficulty, chart in song.charts.items():
|
||||
contents = _dump_memo_chart(
|
||||
difficulty,
|
||||
chart,
|
||||
song.metadata,
|
||||
chart.timing or song.global_timing,
|
||||
circle_free,
|
||||
)
|
||||
files.append(ChartFile(contents, song, difficulty, chart))
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def dump_memo(song: Song, circle_free: bool, folder: Path, name_pattern: str = None):
|
||||
...
|
||||
|
@ -5,10 +5,10 @@ from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from functools import reduce
|
||||
from itertools import chain, product, zip_longest
|
||||
from typing import Mapping, Dict, Iterator, List, Optional, Set, Tuple, Union
|
||||
from typing import Dict, Iterator, List, Mapping, Optional, Set, Tuple, Union
|
||||
|
||||
import constraint
|
||||
from more_itertools import mark_ends
|
||||
from more_itertools import mark_ends, collapse
|
||||
from parsimonious import Grammar, NodeVisitor, ParseError
|
||||
from path import Path
|
||||
|
||||
@ -25,7 +25,7 @@ from jubeatools.song import (
|
||||
|
||||
from ..command import is_command, parse_command
|
||||
from ..files import load_files
|
||||
from ..parser import (
|
||||
from ..load_tools import (
|
||||
CIRCLE_FREE_TO_DECIMAL_TIME,
|
||||
CIRCLE_FREE_TO_NOTE_SYMBOL,
|
||||
LONG_ARROWS,
|
||||
@ -44,8 +44,9 @@ from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
|
||||
memo_chart_line_grammar = Grammar(
|
||||
r"""
|
||||
line = ws position_part ws (timing_part ws)? comment?
|
||||
position_part = ~r"[^*#:|/\s]{4,8}"
|
||||
timing_part = "|" ~r"[^*#:|/\s]+" "|"
|
||||
position_part = ~r"[^*#:||/\s]{4,8}"
|
||||
timing_part = bar ~r"[^*#:||/\s]+" bar
|
||||
bar = "|" / "|"
|
||||
ws = ~r"[\t ]*"
|
||||
comment = ~r"//.*"
|
||||
"""
|
||||
@ -141,16 +142,16 @@ class MemoLoadedSection:
|
||||
# - encodable in shift_jis_2004
|
||||
# Gets added to the list of characters to be ignored in the timing section
|
||||
EMPTY_BEAT_SYMBOLS = {
|
||||
"一", # U+04E00 - CJK UNIFIED IDEOGRAPH-4E00
|
||||
"-", # U+0FF0D - FULLWIDTH HYPHEN-MINUS
|
||||
"ー", # U+030FC - KATAKANA-HIRAGANA PROLONGED SOUND MARK
|
||||
"─", # U+02500 - BOX DRAWINGS LIGHT HORIZONTAL
|
||||
"―", # U+02015 - HORIZONTAL BAR
|
||||
"━", # U+02501 - BOX DRAWINGS HEAVY HORIZONTAL
|
||||
"–", # U+02013 - EN DASH
|
||||
"‐", # U+02010 - HYPHEN
|
||||
"-", # U+0002D - HYPHEN-MINUS
|
||||
"−", # U+02212 - MINUS SIGN
|
||||
"一", # U+4E00 - CJK UNIFIED IDEOGRAPH-4E00
|
||||
"-", # U+FF0D - FULLWIDTH HYPHEN-MINUS
|
||||
"ー", # U+30FC - KATAKANA-HIRAGANA PROLONGED SOUND MARK
|
||||
"─", # U+2500 - BOX DRAWINGS LIGHT HORIZONTAL
|
||||
"―", # U+2015 - HORIZONTAL BAR
|
||||
"━", # U+2501 - BOX DRAWINGS HEAVY HORIZONTAL
|
||||
"–", # U+2013 - EN DASH
|
||||
"‐", # U+2010 - HYPHEN
|
||||
"-", # U+002D - HYPHEN-MINUS
|
||||
"−", # U+2212 - MINUS SIGN
|
||||
}
|
||||
|
||||
|
||||
@ -216,6 +217,7 @@ class MemoParser(JubeatAnalyserParser):
|
||||
self._push_section()
|
||||
|
||||
self.frames.append(frame)
|
||||
self.current_chart_lines = []
|
||||
|
||||
def _push_section(self):
|
||||
self.sections.append(
|
||||
@ -227,7 +229,6 @@ class MemoParser(JubeatAnalyserParser):
|
||||
)
|
||||
)
|
||||
self.frames = []
|
||||
self.current_chart_lines = []
|
||||
self.section_starting_beat += self.beats_per_section
|
||||
|
||||
def finish_last_few_notes(self):
|
||||
@ -277,7 +278,7 @@ class MemoParser(JubeatAnalyserParser):
|
||||
if frame.timing_part:
|
||||
local_symbols = {
|
||||
symbol: Decimal("0.25") * i
|
||||
for i, symbol in enumerate(chain(frame.timing_part))
|
||||
for i, symbol in enumerate(collapse(frame.timing_part))
|
||||
if symbol not in EMPTY_BEAT_SYMBOLS
|
||||
}
|
||||
currently_defined_symbols = ChainMap(local_symbols, section.symbols)
|
||||
|
@ -3,13 +3,14 @@ from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from functools import partial
|
||||
from io import StringIO
|
||||
from itertools import chain
|
||||
from typing import Dict, Iterator, List, Optional, Tuple
|
||||
from typing import Dict, Iterator, List, Mapping, Optional, Tuple
|
||||
|
||||
from more_itertools import collapse, intersperse, mark_ends, windowed
|
||||
from path import Path
|
||||
from sortedcontainers import SortedDict, SortedKeyList, SortedSet
|
||||
from sortedcontainers import SortedKeyList
|
||||
|
||||
from jubeatools import __version__
|
||||
from jubeatools.formats.filetypes import ChartFile, JubeatFile
|
||||
@ -24,97 +25,23 @@ from jubeatools.song import (
|
||||
Timing,
|
||||
)
|
||||
|
||||
from ..command import dump_command
|
||||
from ..dump_tools import (
|
||||
BEATS_TIME_TO_SYMBOL,
|
||||
COMMAND_ORDER,
|
||||
DEFAULT_EXTRA_SYMBOLS,
|
||||
DIFFICULTIES,
|
||||
DIRECTION_TO_ARROW,
|
||||
DIRECTION_TO_LINE,
|
||||
JubeatAnalyserDumpedSection,
|
||||
create_sections_from_chart,
|
||||
LongNoteEnd,
|
||||
SortedDefaultDict,
|
||||
fraction_to_decimal,
|
||||
)
|
||||
from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
|
||||
|
||||
COMMAND_ORDER = [
|
||||
"b",
|
||||
"t",
|
||||
"m",
|
||||
"o",
|
||||
"r",
|
||||
"title",
|
||||
"artist",
|
||||
"lev",
|
||||
"dif",
|
||||
"jacket",
|
||||
"prevpos",
|
||||
]
|
||||
|
||||
BEATS_TIME_TO_SYMBOL = {
|
||||
BeatsTime(1, 4) * index: symbol for index, symbol in enumerate(NOTE_SYMBOLS)
|
||||
}
|
||||
|
||||
BEATS_TIME_TO_CIRCLE_FREE = {
|
||||
BeatsTime(1, 4) * index: symbol for index, symbol in enumerate(CIRCLE_FREE_SYMBOLS)
|
||||
}
|
||||
|
||||
DIRECTION_TO_ARROW = {
|
||||
NotePosition(-1, 0): ">", # U+FF1E : FULLWIDTH GREATER-THAN SIGN
|
||||
NotePosition(1, 0): "<", # U+FF1C : FULLWIDTH LESS-THAN SIGN
|
||||
NotePosition(0, -1): "∨", # U+2228 : LOGICAL OR
|
||||
NotePosition(0, 1): "∧", # U+2227 : LOGICAL AND
|
||||
}
|
||||
|
||||
DIRECTION_TO_LINE = {
|
||||
NotePosition(-1, 0): "―", # U+2015 : HORIZONTAL BAR
|
||||
NotePosition(1, 0): "―",
|
||||
NotePosition(0, -1): "|", # U+FF5C : FULLWIDTH VERTICAL LINE
|
||||
NotePosition(0, 1): "|",
|
||||
}
|
||||
|
||||
DIFFICULTIES = {"BSC": 1, "ADV": 2, "EXT": 3}
|
||||
|
||||
# I put a FUCKTON of extra characters just in case some insane chart uses
|
||||
# loads of unusual beat divisions
|
||||
DEFAULT_EXTRA_SYMBOLS = (
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
"あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん"
|
||||
"アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン"
|
||||
)
|
||||
|
||||
|
||||
def fraction_to_decimal(frac: Fraction):
|
||||
"Thanks stackoverflow ! https://stackoverflow.com/a/40468867/10768117"
|
||||
return frac.numerator / Decimal(frac.denominator)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonoColumnDumpedSection:
|
||||
current_beat: BeatsTime
|
||||
commands: Dict[str, Optional[str]] = field(default_factory=dict)
|
||||
symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict)
|
||||
symbols: Dict[BeatsTime, str] = field(default_factory=dict)
|
||||
notes: List[TapNote] = field(default_factory=list)
|
||||
|
||||
def render(self, circle_free: bool = False) -> str:
|
||||
blocs = []
|
||||
commands = list(self._dump_commands())
|
||||
if commands:
|
||||
blocs.append(commands)
|
||||
symbols = list(self._dump_symbol_definitions())
|
||||
if symbols:
|
||||
blocs.append(symbols)
|
||||
notes = list(self._dump_notes(circle_free))
|
||||
if notes:
|
||||
blocs.append(notes)
|
||||
return "\n".join(collapse([intersperse("", blocs), "--"]))
|
||||
|
||||
def _dump_commands(self) -> Iterator[str]:
|
||||
keys = chain(COMMAND_ORDER, self.commands.keys() - set(COMMAND_ORDER))
|
||||
for key in keys:
|
||||
try:
|
||||
value = self.commands[key]
|
||||
except KeyError:
|
||||
continue
|
||||
yield dump_command(key, value)
|
||||
|
||||
def _dump_symbol_definitions(self) -> Iterator[str]:
|
||||
for time, symbol in self.symbol_definitions.items():
|
||||
decimal_time = fraction_to_decimal(time)
|
||||
yield f"*{symbol}:{decimal_time:.6f}"
|
||||
|
||||
class MonoColumnDumpedSection(JubeatAnalyserDumpedSection):
|
||||
def _dump_notes(self, circle_free: bool = False,) -> Iterator[str]:
|
||||
frames: List[Dict[NotePosition, str]] = []
|
||||
frame: Dict[NotePosition, str] = {}
|
||||
@ -165,28 +92,6 @@ class MonoColumnDumpedSection:
|
||||
yield "".join(frame.get(NotePosition(x, y), "□") for x in range(4))
|
||||
|
||||
|
||||
class Sections(SortedDict):
|
||||
|
||||
"""Custom SortedDict that also acts as a defaultdict of
|
||||
MonoColumnDumpedSection"""
|
||||
|
||||
def add_section(self, beat):
|
||||
if beat not in self:
|
||||
section = MonoColumnDumpedSection(beat)
|
||||
self.__setitem__(beat, section)
|
||||
|
||||
def __missing__(self, beat):
|
||||
section = MonoColumnDumpedSection(beat)
|
||||
self.__setitem__(beat, section)
|
||||
return section
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LongNoteEnd:
|
||||
time: BeatsTime
|
||||
position: NotePosition
|
||||
|
||||
|
||||
def _raise_if_unfit_for_mono_column(
|
||||
chart: Chart, timing: Timing, circle_free: bool = False
|
||||
):
|
||||
@ -217,7 +122,6 @@ def _raise_if_unfit_for_mono_column(
|
||||
" representable in #circlefree mode"
|
||||
)
|
||||
|
||||
|
||||
def _dump_mono_column_chart(
|
||||
difficulty: str,
|
||||
chart: Chart,
|
||||
@ -228,65 +132,8 @@ def _dump_mono_column_chart(
|
||||
|
||||
_raise_if_unfit_for_mono_column(chart, timing, circle_free)
|
||||
|
||||
timing_events = sorted(timing.events, key=lambda e: e.time)
|
||||
notes = SortedKeyList(set(chart.notes), key=lambda n: n.time)
|
||||
|
||||
for note in chart.notes:
|
||||
if isinstance(note, LongNote):
|
||||
notes.add(LongNoteEnd(note.time + note.duration, note.position))
|
||||
|
||||
all_events = SortedKeyList(timing_events + notes, key=lambda n: n.time)
|
||||
last_event = all_events[-1]
|
||||
last_measure = last_event.time // 4
|
||||
sections = Sections()
|
||||
for i in range(last_measure + 1):
|
||||
beat = BeatsTime(4) * i
|
||||
sections.add_section(beat)
|
||||
|
||||
header = sections[0].commands
|
||||
header["o"] = int(timing.beat_zero_offset * 1000)
|
||||
header["lev"] = int(chart.level)
|
||||
header["dif"] = DIFFICULTIES.get(difficulty, 1)
|
||||
if metadata.audio:
|
||||
header["m"] = metadata.audio
|
||||
if metadata.title:
|
||||
header["title"] = metadata.title
|
||||
if metadata.artist:
|
||||
header["artist"] = metadata.artist
|
||||
if metadata.cover:
|
||||
header["jacket"] = metadata.cover
|
||||
if metadata.preview is not None:
|
||||
header["prevpos"] = int(metadata.preview.start * 1000)
|
||||
|
||||
if any(isinstance(note, LongNote) for note in chart.notes):
|
||||
header["holdbyarrow"] = 1
|
||||
|
||||
# Potentially create sub-sections for bpm changes
|
||||
for event in timing_events:
|
||||
sections[event.time].commands["t"] = event.BPM
|
||||
|
||||
# First, Set every single b=… value
|
||||
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
|
||||
if next_key is None:
|
||||
sections[key].commands["b"] = 4
|
||||
else:
|
||||
sections[key].commands["b"] = fraction_to_decimal(next_key - key)
|
||||
|
||||
# Then, trim all the redundant b=…
|
||||
last_b = 4
|
||||
for section in sections.values():
|
||||
current_b = section.commands["b"]
|
||||
if current_b == last_b:
|
||||
del section.commands["b"]
|
||||
else:
|
||||
last_b = current_b
|
||||
|
||||
# Fill sections with notes
|
||||
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
|
||||
sections[key].notes = list(
|
||||
notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False))
|
||||
)
|
||||
|
||||
sections = create_sections_from_chart(MonoColumnDumpedSection, chart, difficulty, timing, metadata, circle_free)
|
||||
|
||||
# Define extra symbols
|
||||
existing_symbols = deepcopy(BEATS_TIME_TO_SYMBOL)
|
||||
extra_symbols = iter(DEFAULT_EXTRA_SYMBOLS)
|
||||
@ -305,7 +152,7 @@ def _dump_mono_column_chart(
|
||||
file = StringIO()
|
||||
file.write(f"// Converted using jubeatools {__version__}\n")
|
||||
file.write(f"// https://github.com/Stepland/jubeatools\n\n")
|
||||
for section_start, section in sections.items():
|
||||
for _, section in sections.items():
|
||||
file.write(section.render(circle_free) + "\n")
|
||||
|
||||
return file
|
||||
|
@ -29,7 +29,7 @@ from jubeatools.song import (
|
||||
|
||||
from ..command import is_command, parse_command
|
||||
from ..files import load_files
|
||||
from ..parser import (
|
||||
from ..load_tools import (
|
||||
CIRCLE_FREE_TO_DECIMAL_TIME,
|
||||
LONG_ARROWS,
|
||||
LONG_DIRECTION,
|
||||
|
@ -6,10 +6,24 @@ from jubeatools.song import BeatsTime, BPMEvent, Chart, Metadata, SecondsTime, T
|
||||
from jubeatools.testutils.strategies import NoteOption
|
||||
from jubeatools.testutils.strategies import notes as notes_strat
|
||||
|
||||
from ..mono_column.dump import _dump_mono_column_chart
|
||||
from ..mono_column.load import MonoColumnParser
|
||||
from ..memo.dump import _dump_memo_chart
|
||||
from ..memo.load import MemoParser
|
||||
|
||||
|
||||
@given(notes_strat(NoteOption.LONGS))
|
||||
def test_many_notes(notes):
|
||||
...
|
||||
timing = Timing(
|
||||
events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0)
|
||||
)
|
||||
chart = Chart(
|
||||
level=0, timing=timing, notes=sorted(notes, key=lambda n: (n.time, n.position))
|
||||
)
|
||||
metadata = Metadata("", "", "", "")
|
||||
string_io = _dump_memo_chart("", chart, metadata, timing)
|
||||
chart = string_io.getvalue()
|
||||
parser = MemoParser()
|
||||
for line in chart.split("\n"):
|
||||
parser.load_line(line)
|
||||
parser.finish_last_few_notes()
|
||||
actual = set(parser.notes())
|
||||
assert notes == actual
|
||||
|
@ -167,7 +167,7 @@ class Song:
|
||||
charts: Mapping[str, Chart] = field(default_factory=MultiDict)
|
||||
global_timing: Optional[Timing] = None
|
||||
|
||||
def merge(self, other: Song) -> Song:
|
||||
def merge(self, other: "Song") -> "Song":
|
||||
if self.metadata != other.metadata:
|
||||
raise ValueError(
|
||||
"Merge conflit in song metadata :\n"
|
||||
|
@ -94,7 +94,7 @@ def bad_notes(draw, longs: bool):
|
||||
note_strat = tap_note()
|
||||
if longs:
|
||||
note_strat = st.one_of(note_strat, long_note())
|
||||
return draw(st.sets(note_strat, max_size=50))
|
||||
return draw(st.sets(note_strat, max_size=32))
|
||||
|
||||
|
||||
@st.composite
|
||||
@ -105,7 +105,7 @@ def notes(draw, options: NoteOption):
|
||||
note_strat = tap_note()
|
||||
if NoteOption.LONGS in options:
|
||||
note_strat = st.one_of(note_strat, long_note())
|
||||
raw_notes = draw(st.sets(note_strat, max_size=50))
|
||||
raw_notes = draw(st.sets(note_strat, max_size=32))
|
||||
|
||||
if NoteOption.COLLISIONS in options:
|
||||
return raw_notes
|
||||
|
Loading…
Reference in New Issue
Block a user