1
0
mirror of synced 2025-03-02 08:11:22 +01:00

Mono-Column passes some simple long notes unit tests

This commit is contained in:
Stepland 2020-07-01 18:23:44 +02:00
parent 414ea5971f
commit 5217651551
6 changed files with 386 additions and 18 deletions

View File

@ -91,7 +91,7 @@ class MonoColumnDumpedSection:
frames: List[Dict[Tuple[int, int], str]] = []
frame: Dict[Tuple[int, int], str] = {}
symbols: Dict[BeatsTime, str] = ChainMap(
BEATS_TIME_TO_SYMBOL, self.extra_symbols
self.extra_symbols, BEATS_TIME_TO_SYMBOL
)
for note in self.notes:
pos = note.position.as_tuple()

View File

@ -1,20 +1,17 @@
import heapq
import re
from collections import ChainMap, defaultdict, namedtuple
from collections import Counter
from copy import deepcopy
from dataclasses import dataclass, field
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from io import StringIO
from itertools import chain, product
from typing import IO, Dict, Iterable, Iterator, List, Optional, Tuple, Union
from itertools import product
from typing import Dict, Iterator, List, Set, Tuple
import warnings
from more_itertools import collapse, intersperse, windowed
import constraint
from parsimonious import Grammar, NodeVisitor, ParseError
from path import Path
from sortedcontainers import SortedKeyList, SortedSet
from jubeatools import __version__
from jubeatools.song import (
BeatsTime,
BPMEvent,
@ -26,6 +23,7 @@ from jubeatools.song import (
Song,
TapNote,
Timing,
Union,
)
from ..command import is_command, parse_command
@ -81,8 +79,10 @@ def is_empty_line(line: str) -> bool:
DIFFICULTIES = {1: "BSC", 2: "ADV", 3: "EXT"}
NOTE_SYMBOLS = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑳㉑㉒㉓㉔㉕㉖㉗㉘㉙㉚㉛㉜㉝㉞㉟㊱㊲㊳㊴㊵㊶㊷㊸㊹㊺㊻㊼㊽㊾㊿"
SYMBOL_TO_DECIMAL_TIME = {
symbol: Decimal("0.25") * index for index, symbol in enumerate("①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯")
symbol: Decimal("0.25") * index for index, symbol in enumerate(NOTE_SYMBOLS)
}
@ -119,7 +119,7 @@ class MonoColumnLoadedSection:
length: Decimal
tempo: Decimal
def blocs(self, bpp=2) -> Iterable[List[List[str]]]:
def blocs(self, bpp=2) -> Iterator[List[List[str]]]:
if bpp not in (1, 2):
raise ValueError(f"Invalid bpp : {bpp}")
elif bpp == 2:
@ -131,6 +131,103 @@ class MonoColumnLoadedSection:
yield [split_line(self.chart_lines[i + j]) for j in range(4)]
@dataclass(frozen=True)
class UnfinishedLongNote:
time: BeatsTime
position: NotePosition
tail_tip: NotePosition
def ends_at(self, end: BeatsTime) -> LongNote:
if end < self.time:
raise ValueError(
f"Invalid end time ({end}) for long note starting at {self.time}"
)
return LongNote(
time=self.time,
position=self.position,
duration=end - self.time,
tail_tip=self.tail_tip,
)
LONG_ARROW_RIGHT = {
">", # U+003E : GREATER-THAN SIGN
"", # U+FF1E : FULLWIDTH GREATER-THAN SIGN
}
LONG_ARROW_LEFT = {
"<", # U+003C : LESS-THAN SIGN
"", # U+FF1C : FULLWIDTH LESS-THAN SIGN
}
LONG_ARROW_DOWN = {
"V", # U+0056 : LATIN CAPITAL LETTER V
"v", # U+0076 : LATIN SMALL LETTER V
"", # U+2164 : ROMAN NUMERAL FIVE
"", # U+2174 : SMALL ROMAN NUMERAL FIVE
"", # U+2228 : LOGICAL OR
"", # U+FF36 : FULLWIDTH LATIN CAPITAL LETTER V
"", # U+FF56 : FULLWIDTH LATIN SMALL LETTER V
}
LONG_ARROW_UP = {
"^", # U+005E : CIRCUMFLEX ACCENT
"", # U+2227 : LOGICAL AND
}
LONG_ARROWS = LONG_ARROW_LEFT | LONG_ARROW_DOWN | LONG_ARROW_UP | LONG_ARROW_RIGHT
LONG_DIRECTION = {
**{c: (1, 0) for c in LONG_ARROW_RIGHT},
**{c: (-1, 0) for c in LONG_ARROW_LEFT},
**{c: (0, 1) for c in LONG_ARROW_DOWN},
**{c: (0, -1) for c in LONG_ARROW_UP},
}
CIRCLE_FREE_SYMBOLS = {
"", # ⎫
"", # ⎪
"", # ⎪
"", # ⎪
"", # ⎬ FULLWIDTH
"", # ⎪
"", # ⎪
"", # ⎪
"", # ⎭
"10", # ⎫
"11", # ⎪
"12", # ⎪
"13", # ⎪
"14", # ⎪
"15", # ⎬ HALFWIDTH
"16", # ⎪
"17", # ⎪
"18", # ⎪
"19", # ⎪
"20", # ⎭
}
CIRCLE_FREE_TO_DECIMAL_TIME = {
c: Decimal("0.25") * i for i, c in enumerate(CIRCLE_FREE_SYMBOLS)
}
def _distance(a: NotePosition, b: NotePosition) -> float:
return abs(complex(*a.as_tuple())-complex(*b.as_tuple()))
def _long_note_solution_heuristic(solution: Dict[NotePosition, NotePosition]) -> Tuple[int, int, int]:
c = Counter(int(_distance(k,v)) for k,v in solution.items())
return (c[3], c[2], c[1])
def _is_simple_solution(solution, domains) -> bool:
return all(
solution[v] == min(domains[v], key=lambda e: _distance(e,v))
for v in solution.keys()
)
def decimal_to_beats(current_beat: Decimal, symbol_timing: Decimal) -> BeatsTime:
decimal_time = current_beat + symbol_timing
return BeatsTime(decimal_time).limit_denominator(240)
class MonoColumnParser:
def __init__(self):
self.music = None
@ -148,6 +245,8 @@ class MonoColumnParser:
self.artist = None
self.jacket = None
self.preview_start = None
self.hold_by_arrow = False
self.circle_free = False
self.sections: List[MonoColumnLoadedSection] = []
def handle_command(self, command, value=None):
@ -210,15 +309,31 @@ class MonoColumnParser:
self.preview_start = int(value)
def do_bpp(self, value):
bpp = int(value)
if self.sections:
raise ValueError(
"jubeatools does not handle changing the bytes per panel value halfway"
)
elif int(value) not in (1, 2):
elif bpp not in (1, 2):
raise ValueError(f"Unexcpected bpp value : {value}")
elif self.circle_free and bpp == 1:
raise ValueError("#bpp can only be 2 when #circlefree is activated")
else:
self.bytes_per_panel = int(value)
def do_holdbyarrow(self, value):
self.hold_by_arrow = int(value) == 1
def do_holdbytilde(self, value):
if int(value):
raise ValueError("jubeatools does not support #holdbytilde")
def do_circlefree(self, raw_value):
activate = bool(int(raw_value))
if activate and self.bytes_per_panel != 2:
raise ValueError("#circlefree can only be activated when #bpp=2")
self.circle_free = activate
def define_symbol(self, symbol: str, timing: Decimal):
bpp = self.bytes_per_panel
length_as_shift_jis = len(symbol.encode("shift_jis_2004"))
@ -273,16 +388,130 @@ class MonoColumnParser:
elif not is_empty_line(line):
raise SyntaxError(f"not a valid #memo line : {line}")
def notes(self) -> Iterable[Union[TapNote, LongNote]]:
def notes(self) -> Iterator[Union[TapNote, LongNote]]:
if self.hold_by_arrow:
yield from self._iter_notes()
else:
yield from self._iter_notes_without_longs()
def _iter_blocs(self) -> Iterator[Tuple[Decimal, MonoColumnLoadedSection, List[List[str]]]]:
current_beat = Decimal(0)
for section in self.sections:
for bloc in section.blocs():
yield current_beat, section, bloc
current_beat += section.length
def _iter_notes(self) -> Iterator[Union[TapNote, LongNote]]:
unfinished_longs: Dict[NotePosition, UnfinishedLongNote] = {}
for current_beat, section, bloc in self._iter_blocs():
should_skip: Set[NotePosition] = set()
# 1/3 : look for ends to unfinished long notes
for pos, unfinished_long in unfinished_longs.items():
x, y = pos.as_tuple()
symbol = bloc[y][x]
if self.circle_free:
if symbol in CIRCLE_FREE_SYMBOLS:
should_skip.add(pos)
symbol_time = CIRCLE_FREE_TO_DECIMAL_TIME[symbol]
note_time = decimal_to_beats(current_beat, symbol_time)
yield unfinished_long.ends_at(note_time)
elif symbol in section.symbols:
raise SyntaxError(
"Can't have a note symbol on the holding square of"
" an unfinished long note when #circlefree is on"
)
else:
if symbol in section.symbols:
should_skip.add(pos)
symbol_time = section.symbols[symbol]
note_time = decimal_to_beats(current_beat, symbol_time)
yield unfinished_long.ends_at(note_time)
unfinished_longs = {
k: unfinished_longs[k]
for k in unfinished_longs.keys() - should_skip
}
# 2/3 : look for new long notes starting on this bloc
arrow_to_note_candidates: Dict[NotePosition, Set[NotePosition]] = {}
for y, x in product(range(4), range(4)):
pos = NotePosition(x, y)
if pos in should_skip:
continue
symbol = bloc[y][x]
if symbol not in LONG_ARROWS:
continue
# at this point we are sure we have a long arrow
# we need to check in its direction for note candidates
note_candidates: Set[Tuple[int, int]] = set()
𝛿pos = LONG_DIRECTION[symbol]
candidate = NotePosition(x, y) + 𝛿pos
while 0 <= candidate.x < 4 and 0 <= candidate.y < 4:
if candidate in should_skip:
continue
new_symbol = bloc[candidate.y][candidate.x]
if new_symbol in section.symbols:
note_candidates.add(candidate)
candidate += 𝛿pos
# if no notes have been crossed, we just ignore the arrow
if note_candidates:
arrow_to_note_candidates[pos] = note_candidates
# Believe it or not, assigning each arrow to a valid note candidate
# involves whipping out a CSP solver
if arrow_to_note_candidates:
problem = constraint.Problem()
for arrow_pos, note_candidates in arrow_to_note_candidates.items():
problem.addVariable(arrow_pos, list(note_candidates))
problem.addConstraint(constraint.AllDifferentConstraint())
solutions = problem.getSolutions()
if not solutions:
raise SyntaxError(
"Invalid long note arrow pattern in bloc :\n"+
"\n".join(''.join(line) for line in bloc)
)
solution = min(solutions, key=_long_note_solution_heuristic)
if len(solutions) > 1 and not _is_simple_solution(solution, arrow_to_note_candidates):
warnings.warn(
"Ambiguous arrow pattern in bloc :\n"+
"\n".join(''.join(line) for line in bloc)+"\n"
"The resulting long notes might not be what you expect"
)
for arrow_pos, note_pos in solution.items():
should_skip.add(arrow_pos)
should_skip.add(note_pos)
symbol = bloc[note_pos.y][note_pos.x]
symbol_time = section.symbols[symbol]
note_time = decimal_to_beats(current_beat, symbol_time)
unfinished_longs[note_pos] = UnfinishedLongNote(
time=note_time,
position=note_pos,
tail_tip=arrow_pos,
)
# 3/3 : find regular notes
for y, x in product(range(4), range(4)):
position = NotePosition(x, y)
if position in should_skip:
continue
symbol = bloc[y][x]
if symbol in section.symbols:
symbol_time = section.symbols[symbol]
note_time = decimal_to_beats(current_beat, symbol_time)
yield TapNote(note_time, position)
def _iter_notes_without_longs(self) -> Iterator[TapNote]:
current_beat = Decimal(0)
for section in self.sections:
for bloc, y, x in product(section.blocs(), range(4), range(4)):
symbol = bloc[y][x]
if symbol in section.symbols:
decimal_time = current_beat + section.symbols[symbol]
fraction_time = BeatsTime(decimal_time).limit_denominator(240)
symbol_time = section.symbols[symbol]
note_time = decimal_to_beats(current_beat, symbol_time)
position = NotePosition(x, y)
yield TapNote(fraction_time, position)
yield TapNote(note_time, position)
current_beat += section.length

View File

@ -1,5 +1,7 @@
from typing import Iterable, Union
import pytest
from jubeatools.song import BeatsTime, LongNote, NotePosition, TapNote
from ..mono_column.load import MonoColumnParser
@ -218,3 +220,100 @@ def test_irregular_beats_per_frame_2():
TapNote(BeatsTime("4.75"), NotePosition(3, 3)),
]
compare_chart_notes(chart, expected)
def test_long_notes():
chart = """
#holdbyarrow=1
--
--
"""
expected = [
LongNote(
time=BeatsTime(0),
position=NotePosition(0, 0),
duration=BeatsTime(4),
tail_tip=NotePosition(3,0),
)
]
compare_chart_notes(chart, expected)
def test_long_notes_ambiguous_case():
chart = """
#holdbyarrow=1
--
--
"""
expected = [
LongNote(BeatsTime(0), NotePosition(x, y), BeatsTime(4), NotePosition(tx, ty))
for (x, y), (tx, ty) in [
((0, 0), (2, 0)),
((1, 0), (3, 0)),
]
]
with pytest.warns(UserWarning):
compare_chart_notes(chart, expected)
@pytest.mark.filterwarnings("error")
def test_long_notes_simple_solution_no_warning():
chart = """
#holdbyarrow=1
--
--
"""
expected = [
LongNote(BeatsTime(0), NotePosition(x, y), BeatsTime(4), NotePosition(tx, ty))
for (x, y), (tx, ty) in [
((1, 1), (0, 1)),
((2, 1), (3, 1)),
]
]
compare_chart_notes(chart, expected)
def test_long_notes_complex_case():
chart = """
#holdbyarrow=1
--
--
"""
expected = [
LongNote(BeatsTime(0), NotePosition(x, y), BeatsTime(4), NotePosition(tx, ty))
for (x, y), (tx, ty) in [
((1, 3), (1, 2)),
((2, 3), (2, 1)),
((3, 3), (0, 3)),
]
]
compare_chart_notes(chart, expected)

View File

@ -44,6 +44,30 @@ class NotePosition:
raise ValueError(f"Note position index out of range : {index}")
return cls(x=index % 4, y=index // 4)
def __lt__(self, other):
if not isinstance(other, NotePosition):
try:
x, y = other
except ValueError:
raise ValueError(f"Cannot add NotePosition with {type(other).__name__}")
else:
x = other.x
y = other.y
return self.as_tuple() < (x, y)
def __add__(self, other):
if not isinstance(other, NotePosition):
try:
x, y = other
except ValueError:
raise ValueError(f"Cannot add NotePosition with {type(other).__name__}")
else:
x = other.x
y = other.y
return NotePosition(self.x+x, self.y+y)
@dataclass(frozen=True)

17
poetry.lock generated
View File

@ -249,6 +249,18 @@ wcwidth = "*"
checkqa-mypy = ["mypy (v0.761)"]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
[[package]]
category = "main"
description = "python-constraint is a module implementing support for handling CSPs (Constraint Solving Problems) over finite domain"
name = "python-constraint"
optional = false
python-versions = "*"
version = "1.4.0"
[package.extras]
dev = ["check-manifest", "nose"]
test = ["coverage", "nose"]
[[package]]
category = "dev"
description = "Alternative regular expression module, to replace re."
@ -325,7 +337,7 @@ python-versions = "*"
version = "0.1.9"
[metadata]
content-hash = "0885667082768fc3615e267b2dcad3b6269723b01bc79cb9851c72b969549eaa"
content-hash = "ea32264fbb53166d279acff6d1adda6bbf3892b58d1c1ccebe0efabd8b661959"
python-versions = "^3.8"
[metadata.files]
@ -439,6 +451,9 @@ pytest = [
{file = "pytest-5.4.2-py3-none-any.whl", hash = "sha256:95c710d0a72d91c13fae35dce195633c929c3792f54125919847fdcdf7caa0d3"},
{file = "pytest-5.4.2.tar.gz", hash = "sha256:eb2b5e935f6a019317e455b6da83dd8650ac9ffd2ee73a7b657a30873d67a698"},
]
python-constraint = [
{file = "python-constraint-1.4.0.tar.bz2", hash = "sha256:501d6f17afe0032dfc6ea6c0f8acc12e44f992733f00e8538961031ef27ccb8e"},
]
regex = [
{file = "regex-2020.5.14-cp27-cp27m-win32.whl", hash = "sha256:e565569fc28e3ba3e475ec344d87ed3cd8ba2d575335359749298a0899fe122e"},
{file = "regex-2020.5.14-cp27-cp27m-win_amd64.whl", hash = "sha256:d466967ac8e45244b9dfe302bbe5e3337f8dc4dec8d7d10f5e950d83b140d33a"},

View File

@ -15,6 +15,7 @@ marshmallow = "^3.6.0"
parsimonious = "^0.8.1"
more-itertools = "^8.4.0"
sortedcontainers = "^2.2.2"
python-constraint = "^1.4.0"
[tool.poetry.dev-dependencies]
pytest = "^5.2"