Mono-Column passes some simple long notes unit tests
This commit is contained in:
parent
414ea5971f
commit
5217651551
@ -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()
|
||||
|
@ -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
|
||||
"V", # U+FF36 : FULLWIDTH LATIN CAPITAL LETTER V
|
||||
"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 = {
|
||||
"1", # ⎫
|
||||
"2", # ⎪
|
||||
"3", # ⎪
|
||||
"4", # ⎪
|
||||
"5", # ⎬ FULLWIDTH
|
||||
"6", # ⎪
|
||||
"7", # ⎪
|
||||
"8", # ⎪
|
||||
"9", # ⎭
|
||||
"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
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
17
poetry.lock
generated
@ -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"},
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user