1
0
mirror of synced 2024-12-04 03:07:16 +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
## Added
- [#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)
"""
from numbers import Number
from typing import Any, List, Optional, Tuple
from decimal import ROUND_HALF_DOWN, Decimal
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.nodes import Node
from jubeatools.utils import fraction_to_decimal
command_grammar = Grammar(
r"""
line = ws command ws comment?
@ -75,7 +80,7 @@ class CommandVisitor(NodeVisitor):
self.key = letter.text
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:
self.value = node.text
@ -105,27 +110,83 @@ def parse_command(line: str) -> Tuple[str, Optional[str]]:
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:
key_part = key
else:
key_part = f"#{key}"
if isinstance(value, Number):
value_part = f"={value}"
elif value is not None:
escaped = dump_value(str(value))
value_part = f'="{escaped}"'
else:
if value is None:
value_part = ""
else:
dumped_value = dump_value(value)
value_part = f"={dumped_value}"
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 = "\\"
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"""
res = []
i = 0
@ -141,11 +202,3 @@ def parse_value(escaped: str) -> str:
i += 1
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 (
Any,
Callable,
DefaultDict,
Dict,
Generic,
Iterator,
List,
Mapping,
Optional,
TypedDict,
TypeVar,
Union,
)
@ -34,6 +34,7 @@ from jubeatools.song import (
TapNote,
Timing,
)
from jubeatools.utils import fraction_to_decimal
from .command import dump_command
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)
class LongNoteEnd:
time: BeatsTime
@ -107,7 +103,7 @@ K = TypeVar("K")
V = TypeVar("V")
class SortedDefaultDict(SortedDict, Generic[K, V]):
class SortedDefaultDict(SortedDict, DefaultDict[K, V]):
"""Custom SortedDict that also acts as a defaultdict,
passes the key to the value factory"""
@ -127,13 +123,36 @@ class SortedDefaultDict(SortedDict, Generic[K, V]):
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
# at once on a single class definition
@dataclass
class _JubeatAnalyerDumpedSection:
current_beat: BeatsTime
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)
symbols: Dict[BeatsTime, str] = field(default_factory=dict)
notes: List[Union[TapNote, LongNote, LongNoteEnd]] = field(default_factory=list)
@ -143,33 +162,31 @@ class JubeatAnalyserDumpedSection(_JubeatAnalyerDumpedSection, ABC):
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)
if key in self.commands:
yield dump_command(key, self.commands[key]) # type: ignore
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}"
yield f"*{symbol}:{decimal_time:.6}"
@abstractmethod
def _dump_notes(self, circle_free: bool) -> Iterator[str]:
...
S = TypeVar("S", bound=JubeatAnalyserDumpedSection)
@abstractmethod
def render(self, circle_free: bool) -> str:
...
def create_sections_from_chart(
section_factory: Callable[[BeatsTime], S],
section_factory: Callable[[BeatsTime], JubeatAnalyserDumpedSection],
chart: Chart,
difficulty: str,
timing: Timing,
metadata: Metadata,
circle_free: bool,
) -> Mapping[BeatsTime, S]:
) -> Mapping[BeatsTime, JubeatAnalyserDumpedSection]:
sections = SortedDefaultDict(section_factory)
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
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
if next_key is None:
length = Decimal(4)
if key is None:
continue
elif next_key is None:
length = BeatsTime(4)
else:
length = fraction_to_decimal(next_key - key)
length = next_key - key
sections[key].commands["b"] = length
sections[key].length = length
# Then, trim all the redundant b=…
last_b = 4
last_b: Union[int, Fraction, Decimal] = 4
for section in sections.values():
current_b = section.commands["b"]
if current_b == last_b:
@ -231,6 +251,7 @@ def create_sections_from_chart(
# Fill sections with notes
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
assert key is not None
sections[key].notes = list(
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)
# 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:
section_beat = event.time - (event.time % 4)
sections[section_beat].events.append(event)
# Fill sections with notes
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
assert key is not None
sections[key].notes = list(
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 hypothesis import example, given
from hypothesis import note as hypothesis_note
from hypothesis import strategies as st
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(""))
string_io = _dump_memo_chart("", chart, metadata, timing, False)
chart_text = string_io.getvalue()
hypothesis_note(f"Chart :\n{chart_text}")
parser = MemoParser()
for line in chart_text.split("\n"):
parser.load_line(line)

View File

@ -1,11 +1,11 @@
from hypothesis import given
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())
def test_that_strings_roundtrip(expected: str) -> None:
dumped = dump_value(expected)
actual = parse_value(dumped)
def test_that_escaping_preserves_string(expected: str) -> None:
escaped = escape_string_value(expected)
actual = unescape_string_value(escaped)
assert expected == actual

View File

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