1
0
mirror of synced 2024-12-04 19:17:55 +01:00

[jubeat-analyser] Prettier rendering of decimal values

This commit is contained in:
Stepland 2021-05-04 16:11:16 +02:00
parent bb48c4ef0b
commit 996deb3ed3
7 changed files with 137 additions and 47 deletions

View File

@ -1,3 +1,7 @@
# Unreleased
## Fixed
- [jubeat-analyser] Prettier rendering of decimal values
# v0.2.0 # v0.2.0
## Added ## Added
- [#mono-column] #circlefree mode accepts non-16ths notes and falls back to normal symbols when needed - [#mono-column] #circlefree mode accepts non-16ths notes and falls back to normal symbols when needed

View File

@ -24,12 +24,17 @@ Known hash commands :
- #bpp # bytes per panel (2 by default) - #bpp # bytes per panel (2 by default)
""" """
from numbers import Number from decimal import ROUND_HALF_DOWN, Decimal
from typing import Any, List, Optional, Tuple from fractions import Fraction
from functools import singledispatch
from pathlib import Path
from typing import Any, List, Optional, Tuple, Union
from parsimonious import Grammar, NodeVisitor, ParseError from parsimonious import Grammar, NodeVisitor, ParseError
from parsimonious.nodes import Node from parsimonious.nodes import Node
from jubeatools.utils import fraction_to_decimal
command_grammar = Grammar( command_grammar = Grammar(
r""" r"""
line = ws command ws comment? line = ws command ws comment?
@ -75,7 +80,7 @@ class CommandVisitor(NodeVisitor):
self.key = letter.text self.key = letter.text
def visit_quoted_value(self, node: Node, visited_children: List[Node]) -> None: def visit_quoted_value(self, node: Node, visited_children: List[Node]) -> None:
self.value = parse_value(node.text) self.value = unescape_string_value(node.text)
def visit_number(self, node: Node, visited_children: List[Node]) -> None: def visit_number(self, node: Node, visited_children: List[Node]) -> None:
self.value = node.text self.value = node.text
@ -105,27 +110,83 @@ def parse_command(line: str) -> Tuple[str, Optional[str]]:
raise raise
def dump_command(key: str, value: Any = None) -> str: CommandValue = Union[str, Path, int, Decimal, Fraction]
def dump_command(key: str, value: Optional[CommandValue] = None) -> str:
if len(key) == 1: if len(key) == 1:
key_part = key key_part = key
else: else:
key_part = f"#{key}" key_part = f"#{key}"
if isinstance(value, Number): if value is None:
value_part = f"={value}"
elif value is not None:
escaped = dump_value(str(value))
value_part = f'="{escaped}"'
else:
value_part = "" value_part = ""
else:
dumped_value = dump_value(value)
value_part = f"={dumped_value}"
return key_part + value_part return key_part + value_part
@singledispatch
def dump_value(value: CommandValue) -> str:
return str(value)
@dump_value.register
def dump_int_value(value: int) -> str:
return str(value)
@dump_value.register
def dump_decimal_value(value: Decimal) -> str:
return pretty_print_decimal(value)
@dump_value.register
def dump_fraction_value(value: Fraction) -> str:
decimal = fraction_to_decimal(value)
quantized = decimal.quantize(Decimal("0.000001"), rounding=ROUND_HALF_DOWN)
return pretty_print_decimal(quantized)
@dump_value.register
def dump_string_value(value: str) -> str:
"""Escapes backslashes and " from a string"""
escaped = escape_string_value(value)
quoted = f'"{escaped}"'
return quoted
@dump_value.register
def dump_path_value(value: Path) -> str:
if value == Path(""):
string_value = ""
else:
string_value = str(value)
escaped = escape_string_value(string_value)
quoted = f'"{escaped}"'
return quoted
def pretty_print_decimal(d: Decimal) -> str:
raw_string_form = str(d)
if "." in raw_string_form:
return raw_string_form.rstrip("0").rstrip(".")
else:
return raw_string_form
BACKSLASH = "\\" BACKSLASH = "\\"
ESCAPE_TABLE = str.maketrans({'"': BACKSLASH + '"', BACKSLASH: BACKSLASH + BACKSLASH})
def parse_value(escaped: str) -> str: def escape_string_value(unescaped: str) -> str:
return unescaped.translate(ESCAPE_TABLE)
def unescape_string_value(escaped: str) -> str:
"""Unescapes a backslash-escaped string""" """Unescapes a backslash-escaped string"""
res = [] res = []
i = 0 i = 0
@ -141,11 +202,3 @@ def parse_value(escaped: str) -> str:
i += 1 i += 1
return "".join(res) return "".join(res)
ESCAPE_TABLE = str.maketrans({'"': BACKSLASH + '"', BACKSLASH: BACKSLASH + BACKSLASH})
def dump_value(value: str) -> str:
"""Escapes backslashes and " from a string"""
return value.translate(ESCAPE_TABLE)

View File

@ -9,12 +9,12 @@ from pathlib import Path
from typing import ( from typing import (
Any, Any,
Callable, Callable,
DefaultDict,
Dict, Dict,
Generic,
Iterator, Iterator,
List, List,
Mapping, Mapping,
Optional, TypedDict,
TypeVar, TypeVar,
Union, Union,
) )
@ -34,6 +34,7 @@ from jubeatools.song import (
TapNote, TapNote,
Timing, Timing,
) )
from jubeatools.utils import fraction_to_decimal
from .command import dump_command from .command import dump_command
from .symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS from .symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
@ -92,11 +93,6 @@ DEFAULT_EXTRA_SYMBOLS = (
) )
def fraction_to_decimal(frac: Fraction) -> Decimal:
"Thanks stackoverflow ! https://stackoverflow.com/a/40468867/10768117"
return frac.numerator / Decimal(frac.denominator)
@dataclass(frozen=True) @dataclass(frozen=True)
class LongNoteEnd: class LongNoteEnd:
time: BeatsTime time: BeatsTime
@ -107,7 +103,7 @@ K = TypeVar("K")
V = TypeVar("V") V = TypeVar("V")
class SortedDefaultDict(SortedDict, Generic[K, V]): class SortedDefaultDict(SortedDict, DefaultDict[K, V]):
"""Custom SortedDict that also acts as a defaultdict, """Custom SortedDict that also acts as a defaultdict,
passes the key to the value factory""" passes the key to the value factory"""
@ -127,13 +123,36 @@ class SortedDefaultDict(SortedDict, Generic[K, V]):
return value return value
class Commands(TypedDict, total=False):
b: Union[int, Fraction, Decimal]
t: Union[int, Fraction, Decimal]
m: Union[str, Path]
o: int
r: int
title: str
artist: str
lev: Union[int, Decimal]
dif: int
jacket: Union[str, Path]
prevpos: int
holdbyarrow: int
circlefree: int
memo: None
memo1: None
memo2: None
boogie: None
pw: int
ph: int
bpp: int
# Here we split dataclass and ABC stuff since mypy curently can't handle both # Here we split dataclass and ABC stuff since mypy curently can't handle both
# at once on a single class definition # at once on a single class definition
@dataclass @dataclass
class _JubeatAnalyerDumpedSection: class _JubeatAnalyerDumpedSection:
current_beat: BeatsTime current_beat: BeatsTime
length: BeatsTime = BeatsTime(4) length: BeatsTime = BeatsTime(4)
commands: Dict[str, Optional[str]] = field(default_factory=dict) commands: Commands = field(default_factory=Commands) # type: ignore
symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict) symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict)
symbols: 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) notes: List[Union[TapNote, LongNote, LongNoteEnd]] = field(default_factory=list)
@ -143,33 +162,31 @@ class JubeatAnalyserDumpedSection(_JubeatAnalyerDumpedSection, ABC):
def _dump_commands(self) -> Iterator[str]: def _dump_commands(self) -> Iterator[str]:
keys = chain(COMMAND_ORDER, self.commands.keys() - set(COMMAND_ORDER)) keys = chain(COMMAND_ORDER, self.commands.keys() - set(COMMAND_ORDER))
for key in keys: for key in keys:
try: if key in self.commands:
value = self.commands[key] yield dump_command(key, self.commands[key]) # type: ignore
except KeyError:
continue
yield dump_command(key, value)
def _dump_symbol_definitions(self) -> Iterator[str]: def _dump_symbol_definitions(self) -> Iterator[str]:
for time, symbol in self.symbol_definitions.items(): for time, symbol in self.symbol_definitions.items():
decimal_time = fraction_to_decimal(time) decimal_time = fraction_to_decimal(time)
yield f"*{symbol}:{decimal_time:.6f}" yield f"*{symbol}:{decimal_time:.6}"
@abstractmethod @abstractmethod
def _dump_notes(self, circle_free: bool) -> Iterator[str]: def _dump_notes(self, circle_free: bool) -> Iterator[str]:
... ...
@abstractmethod
S = TypeVar("S", bound=JubeatAnalyserDumpedSection) def render(self, circle_free: bool) -> str:
...
def create_sections_from_chart( def create_sections_from_chart(
section_factory: Callable[[BeatsTime], S], section_factory: Callable[[BeatsTime], JubeatAnalyserDumpedSection],
chart: Chart, chart: Chart,
difficulty: str, difficulty: str,
timing: Timing, timing: Timing,
metadata: Metadata, metadata: Metadata,
circle_free: bool, circle_free: bool,
) -> Mapping[BeatsTime, S]: ) -> Mapping[BeatsTime, JubeatAnalyserDumpedSection]:
sections = SortedDefaultDict(section_factory) sections = SortedDefaultDict(section_factory)
timing_events = sorted(timing.events, key=lambda e: e.time) timing_events = sorted(timing.events, key=lambda e: e.time)
@ -213,15 +230,18 @@ def create_sections_from_chart(
# First, Set every single b=… value # First, Set every single b=… value
for key, next_key in windowed(chain(sections.keys(), [None]), 2): for key, next_key in windowed(chain(sections.keys(), [None]), 2):
if next_key is None: if key is None:
length = Decimal(4) continue
elif next_key is None:
length = BeatsTime(4)
else: else:
length = fraction_to_decimal(next_key - key) length = next_key - key
sections[key].commands["b"] = length sections[key].commands["b"] = length
sections[key].length = length sections[key].length = length
# Then, trim all the redundant b=… # Then, trim all the redundant b=…
last_b = 4 last_b: Union[int, Fraction, Decimal] = 4
for section in sections.values(): for section in sections.values():
current_b = section.commands["b"] current_b = section.commands["b"]
if current_b == last_b: if current_b == last_b:
@ -231,6 +251,7 @@ def create_sections_from_chart(
# Fill sections with notes # Fill sections with notes
for key, next_key in windowed(chain(sections.keys(), [None]), 2): for key, next_key in windowed(chain(sections.keys(), [None]), 2):
assert key is not None
sections[key].notes = list( sections[key].notes = list(
notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False)) notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False))
) )

View File

@ -281,13 +281,16 @@ def _dump_memo2_chart(
sections.add_key(beat) sections.add_key(beat)
# Timing events # Timing events
sections[0].events.append(StopEvent(BeatsTime(0), timing.beat_zero_offset)) sections[BeatsTime(0)].events.append(
StopEvent(BeatsTime(0), timing.beat_zero_offset)
)
for event in timing_events: for event in timing_events:
section_beat = event.time - (event.time % 4) section_beat = event.time - (event.time % 4)
sections[section_beat].events.append(event) sections[section_beat].events.append(event)
# Fill sections with notes # Fill sections with notes
for key, next_key in windowed(chain(sections.keys(), [None]), 2): for key, next_key in windowed(chain(sections.keys(), [None]), 2):
assert key is not None
sections[key].notes = list( sections[key].notes = list(
notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False)) notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False))
) )

View File

@ -3,6 +3,7 @@ from pathlib import Path
from typing import Set, Union from typing import Set, Union
from hypothesis import example, given from hypothesis import example, given
from hypothesis import note as hypothesis_note
from hypothesis import strategies as st from hypothesis import strategies as st
from jubeatools import song from jubeatools import song
@ -30,6 +31,7 @@ def test_that_notes_roundtrip(notes: Set[Union[song.TapNote, song.LongNote]]) ->
metadata = song.Metadata("", "", Path(""), Path("")) metadata = song.Metadata("", "", Path(""), Path(""))
string_io = _dump_memo_chart("", chart, metadata, timing, False) string_io = _dump_memo_chart("", chart, metadata, timing, False)
chart_text = string_io.getvalue() chart_text = string_io.getvalue()
hypothesis_note(f"Chart :\n{chart_text}")
parser = MemoParser() parser = MemoParser()
for line in chart_text.split("\n"): for line in chart_text.split("\n"):
parser.load_line(line) parser.load_line(line)

View File

@ -1,11 +1,11 @@
from hypothesis import given from hypothesis import given
from hypothesis import strategies as st from hypothesis import strategies as st
from ..command import dump_value, parse_value from ..command import escape_string_value, unescape_string_value
@given(st.text()) @given(st.text())
def test_that_strings_roundtrip(expected: str) -> None: def test_that_escaping_preserves_string(expected: str) -> None:
dumped = dump_value(expected) escaped = escape_string_value(expected)
actual = parse_value(dumped) actual = unescape_string_value(escaped)
assert expected == actual assert expected == actual

View File

@ -1,4 +1,6 @@
import unicodedata import unicodedata
from decimal import Decimal
from fractions import Fraction
from functools import reduce from functools import reduce
from math import gcd from math import gcd
from typing import Callable, Optional, TypeVar from typing import Callable, Optional, TypeVar
@ -28,3 +30,8 @@ def none_or(c: Callable[[A], B], e: Optional[A]) -> Optional[B]:
return None return None
else: else:
return c(e) return c(e)
def fraction_to_decimal(frac: Fraction) -> Decimal:
"Thanks stackoverflow ! https://stackoverflow.com/a/40468867/10768117"
return frac.numerator / Decimal(frac.denominator)