nnnotes 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/score.py
ADDED
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
"""Live chart (MusicScore) extraction and a re-implementation of the game's chart converter.
|
|
2
|
+
|
|
3
|
+
Each function follows the game method named in its docstring. The pipeline,
|
|
4
|
+
as in the game:
|
|
5
|
+
|
|
6
|
+
MusicScoreLoader.Load (gunzip when the bytes start 1F 8B)
|
|
7
|
+
SsRootDeserializer (JSON -> SsRoot, field defaults) -> parse()
|
|
8
|
+
SsTickConverter (tick -> bar position / ms) -> TickConverter
|
|
9
|
+
SsMusicScoreConverter (SsNote -> NoteInfoData, merges, lines) -> _build_note_infos()
|
|
10
|
+
MusicScoreNoteCreator (NoteInfoData -> notes, slide combo ticks)-> _Creator
|
|
11
|
+
MusicScoreUtility.GetTimeMsFromBar (note time, float32) -> time_ms_from_bar()
|
|
12
|
+
|
|
13
|
+
`convert()` returns the runtime note list (the notes the game puts in
|
|
14
|
+
MusicScore.NoteDictionary, including SlideComboNote ticks) in position order, the
|
|
15
|
+
note lines and the events. `runtime_score()` gives the same notes in the order the
|
|
16
|
+
game enumerates MusicScore.NoteDictionary. `extract()` writes the shipped chart,
|
|
17
|
+
the converted notes, the master rows and the decoded song for one music/difficulty.
|
|
18
|
+
|
|
19
|
+
Game resources are only read from the user's own catalog/cache and written to
|
|
20
|
+
the user's output directory.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import gzip
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from . import cri, languages
|
|
33
|
+
from .jsonio import write_json
|
|
34
|
+
|
|
35
|
+
f32 = np.float32
|
|
36
|
+
|
|
37
|
+
LANE_COUNT = 24 # MusicScore.LaneCount (SsMusicScoreConverter.Load sets 0x18)
|
|
38
|
+
TICKS_PER_BEAT = 480 # 60000 / (bpm * 480) in SsTickConverter.TickToTimeMs
|
|
39
|
+
TICKS_PER_WHOLE = 1920 # ticks per measure = numerator * 1920 / denominator
|
|
40
|
+
MAX_LINES = 20 # LineIndexAssigner(0x14)
|
|
41
|
+
DIFFICULTIES = ("easy", "normal", "hard", "expert") # _easyID.._expertID -> file suffix _00.._03
|
|
42
|
+
|
|
43
|
+
# SsNoteType / SsFlickDir / SsEase / SsFadeType (SsRootDeserializer.Parse*)
|
|
44
|
+
NOTE_TYPES = {"tap": 0, "flick": 1, "trace": 2, "long": 3, "guide": 4, "node": 5}
|
|
45
|
+
FLICK_DIRS = {"up": 0, "left": 1, "right": 2, "down": 3}
|
|
46
|
+
EASES = {"linear": 0, "in": 1, "out": 2}
|
|
47
|
+
FADES = {"none": 0, "in": 1, "out": 2} # unknown string -> none (no exception)
|
|
48
|
+
|
|
49
|
+
# NoteOperateType
|
|
50
|
+
OP_NAMES = {
|
|
51
|
+
0: "None", 1: "Normal", 20: "SlideBegin", 21: "SlideConnection", 22: "SlideEnd",
|
|
52
|
+
40: "Flick", 41: "SlideBeginFlick", 42: "SlideEndFlick", 60: "Trace", 61: "SlideBeginTrace",
|
|
53
|
+
62: "SlideEndTrace", 63: "SlideConnectionTrace", 80: "HiddenSlideBegin", 82: "HiddenSlideEnd",
|
|
54
|
+
100: "GuideBegin", 101: "GuideBeginNormal", 102: "GuideBeginFlick", 103: "GuideEnd",
|
|
55
|
+
104: "GuideBeginTrace", 105: "GuideEndTrace", 120: "Combo", 121: "ComboSkip", 122: "Hidden",
|
|
56
|
+
123: "InvalidHidden",
|
|
57
|
+
}
|
|
58
|
+
# NoteLineEaseType as the runtime names it (LiveCalculator.GetNoteLineTypeEasing):
|
|
59
|
+
# 0 Linear t, 1 "EaseIn" (2-t)t, 2 "EaseOut" t*t. Chart "in" maps to 2 and "out" to 1 (ConvertEase).
|
|
60
|
+
LINE_EASE_NAMES = {0: "Linear", 1: "EaseIn", 2: "EaseOut"}
|
|
61
|
+
DIRECTION_NAMES = {0: "Normal", 1: "Left", 2: "Right"}
|
|
62
|
+
# NoteJudgementType (FTLiveSimulator.Runtime)
|
|
63
|
+
JUDGEMENT_TYPE_NAMES = {0: "None", 1: "Normal", 2: "EasyNormal", 5: "Flick", 10: "SlideBegin",
|
|
64
|
+
11: "SlideEnd", 12: "SlideEndFlick", 15: "SlideBeginEasy", 21: "Trace",
|
|
65
|
+
22: "SlideEndTrace"}
|
|
66
|
+
# JudgementAreaOffsetType
|
|
67
|
+
OFFSET_TYPE_NAMES = {0: "Default", 1: "Slide", 2: "SlideBegin", 3: "SlideEnd", 4: "Flick", 5: "Trace",
|
|
68
|
+
6: "SlideMin", 7: "SlideMax", 8: "EasyDefault", 9: "EasySlideBegin"}
|
|
69
|
+
|
|
70
|
+
SLIDE_BEGIN_CLASS = {20, 41, 61, 80, 104} # classes derived from SlideBeginNote (have AddCombo)
|
|
71
|
+
SLIDE_END_CLASS = {22, 42, 62, 82, 105} # classes derived from SlideEndNote (ISlideNoteEnd)
|
|
72
|
+
GUIDE_BEGIN_OPS = {100, 101, 102, 104}
|
|
73
|
+
GUIDE_END_OPS = {103, 105}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------------------- numeric helpers
|
|
77
|
+
def net_round(x: float) -> float:
|
|
78
|
+
"""System.Math.Round(double) (MidpointRounding.ToEven) exactly as the inlined IL2CPP code does it."""
|
|
79
|
+
x = float(x)
|
|
80
|
+
frac, ip = math.modf(x)
|
|
81
|
+
if x >= 0.0:
|
|
82
|
+
if frac == 0.5:
|
|
83
|
+
return ip + 1.0 if int(ip) & 1 else ip
|
|
84
|
+
return float(math.floor(x + 0.5))
|
|
85
|
+
if frac == -0.5:
|
|
86
|
+
return ip - 1.0 if int(ip) & 1 else ip
|
|
87
|
+
return float(int(x - 0.5)) # (double)(long)(x - 0.5): truncation toward zero
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def net_round_int(x: float) -> int:
|
|
91
|
+
v = net_round(x)
|
|
92
|
+
return int(v) if math.isfinite(v) else -2**31
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_judgement_note(op: int) -> bool:
|
|
96
|
+
"""NoteOperateTypeUtility.IsJudgementNote."""
|
|
97
|
+
return op not in (0, 80, 82, 100, 103, 121, 122, 123)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_flick_type(op: int) -> bool:
|
|
101
|
+
"""NoteOperateTypeUtility.IsFlickType."""
|
|
102
|
+
return op in (40, 41, 42, 102)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def judgement_type(op: int, crit: bool) -> int:
|
|
106
|
+
"""NoteJudgementTypeMap.ConvertJudgementType."""
|
|
107
|
+
if op in (0, 21):
|
|
108
|
+
return op
|
|
109
|
+
if op == 1:
|
|
110
|
+
return 2 if crit else 1
|
|
111
|
+
if op == 20:
|
|
112
|
+
return 15 if crit else 10
|
|
113
|
+
if op == 22:
|
|
114
|
+
return 11
|
|
115
|
+
if op in (40, 41, 42, 102):
|
|
116
|
+
return 5
|
|
117
|
+
if op in (60, 61, 63, 104, 105, 120):
|
|
118
|
+
return 21
|
|
119
|
+
if op == 62:
|
|
120
|
+
return 22
|
|
121
|
+
if op in (80, 82, 100, 101, 103, 121, 122):
|
|
122
|
+
return 1
|
|
123
|
+
raise ValueError(f"ConvertJudgementType: operateType {op}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
MATHF_EPSILON = f32(1.401298e-45) # Mathf.Epsilon (denormal min; 1.17549435e-38 if flush-to-zero is on, assumed off)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def mathf_approximately(a: float, b: float) -> bool:
|
|
130
|
+
"""UnityEngine.Mathf.Approximately (inlined in IsSamePosition / EqualMusicInfoPosition)."""
|
|
131
|
+
a, b = f32(a), f32(b)
|
|
132
|
+
m = max(abs(a), abs(b))
|
|
133
|
+
tol = max(f32(m * f32(1e-6)), f32(MATHF_EPSILON * f32(8.0)))
|
|
134
|
+
return bool(abs(f32(b - a)) < tol)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------------- SsRoot (ReadNote/ReadEvents)
|
|
138
|
+
@dataclass
|
|
139
|
+
class SsNote:
|
|
140
|
+
type: int = 0 # SsNoteType, default Tap
|
|
141
|
+
t: int = 0
|
|
142
|
+
pos: np.float32 = f32(0.0)
|
|
143
|
+
pos_auto: bool = False
|
|
144
|
+
size: np.float32 = f32(6.0) # ReadNote writes 6.0f (0x40c00000) before parsing
|
|
145
|
+
crit: bool = False
|
|
146
|
+
dir: int = 0 # SsFlickDir, default Up
|
|
147
|
+
ease_l: int = 0 # SsEase
|
|
148
|
+
ease_r: int = 0
|
|
149
|
+
visible: bool = True # ReadNote writes 1 before parsing
|
|
150
|
+
alpha: int = 0 # SsFadeType
|
|
151
|
+
node: list | None = None
|
|
152
|
+
src: tuple = () # (note index, node index) in the shipped JSON, for traceability
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _to_int(v) -> int:
|
|
156
|
+
"""(int)JToken: Newtonsoft converts floats with Convert.ToInt32 (round half to even)."""
|
|
157
|
+
if isinstance(v, bool):
|
|
158
|
+
return int(v)
|
|
159
|
+
if isinstance(v, int):
|
|
160
|
+
return v
|
|
161
|
+
if isinstance(v, float):
|
|
162
|
+
return net_round_int(v)
|
|
163
|
+
return int(str(v).strip())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _to_f32(v) -> np.float32:
|
|
167
|
+
if isinstance(v, str):
|
|
168
|
+
return f32(float(v))
|
|
169
|
+
return f32(v)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _enum(table: dict, s: str, what: str) -> int:
|
|
173
|
+
if s not in table:
|
|
174
|
+
raise ValueError(f"ss {what} value '{s}'") # InvalidOperationException("ss" + s + ...)
|
|
175
|
+
return table[s]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def read_note(n: dict, src: tuple = ()) -> SsNote:
|
|
179
|
+
"""SsRootDeserializer.ReadNote."""
|
|
180
|
+
o = SsNote(src=src)
|
|
181
|
+
if isinstance(n.get("type"), str):
|
|
182
|
+
o.type = _enum(NOTE_TYPES, n["type"], "type")
|
|
183
|
+
if n.get("t") is not None:
|
|
184
|
+
o.t = _to_int(n["t"])
|
|
185
|
+
p = n.get("pos")
|
|
186
|
+
if p is not None:
|
|
187
|
+
if isinstance(p, str) and p == "auto":
|
|
188
|
+
o.pos_auto = True
|
|
189
|
+
else:
|
|
190
|
+
o.pos = _to_f32(p)
|
|
191
|
+
if n.get("size") is not None:
|
|
192
|
+
o.size = _to_f32(n["size"])
|
|
193
|
+
if n.get("crit") is not None:
|
|
194
|
+
o.crit = bool(n["crit"])
|
|
195
|
+
if isinstance(n.get("dir"), str):
|
|
196
|
+
o.dir = _enum(FLICK_DIRS, n["dir"], "dir")
|
|
197
|
+
e = n.get("ease")
|
|
198
|
+
if e is not None:
|
|
199
|
+
if isinstance(e, list):
|
|
200
|
+
o.ease_l = _enum(EASES, e[0], "ease")
|
|
201
|
+
o.ease_r = _enum(EASES, e[1], "ease")
|
|
202
|
+
else:
|
|
203
|
+
o.ease_l = o.ease_r = _enum(EASES, e, "ease")
|
|
204
|
+
if n.get("visible") is not None:
|
|
205
|
+
o.visible = bool(n["visible"])
|
|
206
|
+
if isinstance(n.get("alpha"), str):
|
|
207
|
+
o.alpha = FADES.get(n["alpha"], 0)
|
|
208
|
+
if isinstance(n.get("node"), list):
|
|
209
|
+
o.node = [read_note(c, src + (i,)) for i, c in enumerate(n["node"])]
|
|
210
|
+
return o
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class SsEvents:
|
|
215
|
+
bpm: list = field(default_factory=list) # [(t, bpm f32)]
|
|
216
|
+
sig: list = field(default_factory=list) # [(t, num, den)]
|
|
217
|
+
skill: list = field(default_factory=list) # [t]
|
|
218
|
+
fever: list = field(default_factory=list) # [(t0, t1)]
|
|
219
|
+
call: list = field(default_factory=list) # [(t, [timing ints])]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def read_events(ev: dict) -> SsEvents:
|
|
223
|
+
"""SsRootDeserializer.ReadEvents."""
|
|
224
|
+
o = SsEvents()
|
|
225
|
+
for b in ev.get("bpm") or []:
|
|
226
|
+
o.bpm.append((_to_int(b["t"]), _to_f32(b["bpm"])))
|
|
227
|
+
for s in ev.get("sig") or []:
|
|
228
|
+
o.sig.append((_to_int(s["t"]), _to_int(s["sig"][0]), _to_int(s["sig"][1])))
|
|
229
|
+
for t in ev.get("skill") or []:
|
|
230
|
+
o.skill.append(_to_int(t))
|
|
231
|
+
for f in ev.get("fever") or []:
|
|
232
|
+
o.fever.append((_to_int(f[0]), _to_int(f[1])))
|
|
233
|
+
for c in ev.get("call") or []:
|
|
234
|
+
o.call.append((_to_int(c["t"]), [_to_int(x) for x in c["timing"]]))
|
|
235
|
+
return o
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def load_bytes(data: bytes) -> dict:
|
|
239
|
+
"""MusicScoreLoader.Load: gunzip when the first two bytes are 1F 8B, then UTF-8 JSON."""
|
|
240
|
+
if len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B:
|
|
241
|
+
data = gzip.decompress(data)
|
|
242
|
+
return json.loads(data.decode("utf-8"))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def parse(root: dict) -> tuple[SsEvents, list[SsNote]]:
|
|
246
|
+
"""SsRootDeserializer.ReadRoot/ReadScore: only `score` is read (`meta` is ignored)."""
|
|
247
|
+
score = root.get("score")
|
|
248
|
+
if not isinstance(score, dict):
|
|
249
|
+
raise ValueError("ss score") # SsMusicScoreConverter.Error("ss score")
|
|
250
|
+
ev = read_events(score["events"]) if isinstance(score.get("events"), dict) else SsEvents()
|
|
251
|
+
notes = [read_note(n, (i,)) for i, n in enumerate(score.get("notes") or [])]
|
|
252
|
+
return ev, notes
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# --------------------------------------------------------------------------- SsTickConverter
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class BarPos:
|
|
258
|
+
bar: int
|
|
259
|
+
rhythm: int
|
|
260
|
+
unit: int # RhythmicUnit = ticks per measure of the segment
|
|
261
|
+
progress: np.float32 # (float)rhythm / (float)unit
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def key(self) -> np.float32: # BarProgress + (float)Bar, the dictionary key of the converter
|
|
265
|
+
return f32(self.progress + f32(self.bar))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TickConverter:
|
|
269
|
+
"""SsTickConverter (its constructor builds the sig / bpm segments)."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, sigs: list, bpms: list):
|
|
272
|
+
self.sig = self._sig_segments(sigs)
|
|
273
|
+
self.bpm = self._bpm_segments(bpms)
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _sig_segments(sigs):
|
|
277
|
+
"""BuildSigSegments -> [(startTick, startBar, ticksPerMeasure, num, den)]."""
|
|
278
|
+
s = sorted(sigs, key=lambda x: x[0]) # List.Sort(Comparison by T); ties keep chart order here
|
|
279
|
+
out = []
|
|
280
|
+
if not s or s[0][0] > 0:
|
|
281
|
+
out.append((0, 0, 1920, 4, 4))
|
|
282
|
+
bar, prev_t, tpm = 0, 0, 1920
|
|
283
|
+
for t, num, den in s:
|
|
284
|
+
q = int((t - prev_t) / tpm) if tpm != 0 else 0 # C# int division truncates
|
|
285
|
+
if t - prev_t != 0 and prev_t <= t:
|
|
286
|
+
bar += q
|
|
287
|
+
tpm = int(num * 1920 / den) if den != 0 else 0
|
|
288
|
+
out.append((t, bar, tpm, num, den))
|
|
289
|
+
prev_t = t
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _bpm_segments(bpms):
|
|
294
|
+
"""BuildBpmSegments -> [(startTick, startMs, bpm f32)]; startMs = Math.Round(acc)."""
|
|
295
|
+
s = sorted(bpms, key=lambda x: x[0])
|
|
296
|
+
out = []
|
|
297
|
+
if not s or s[0][0] > 0:
|
|
298
|
+
out.append((0, 0, f32(120.0)))
|
|
299
|
+
acc, prev_t, prev_bpm = 0.0, 0, f32(120.0)
|
|
300
|
+
for t, bpm in s:
|
|
301
|
+
if t - prev_t != 0 and prev_t <= t:
|
|
302
|
+
acc += (float(t - prev_t) * 60000.0) / float(f32(prev_bpm * f32(480.0)))
|
|
303
|
+
out.append((t, net_round_int(acc), bpm))
|
|
304
|
+
prev_t, prev_bpm = t, bpm
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
def _find(self, segs, tick):
|
|
308
|
+
for s in reversed(segs):
|
|
309
|
+
if s[0] <= tick:
|
|
310
|
+
return s
|
|
311
|
+
return segs[0]
|
|
312
|
+
|
|
313
|
+
def time_ms(self, tick: int) -> int:
|
|
314
|
+
"""TickToTimeMs: floor(double(dt)*60000.0 / double(float(bpm*480f)) + startMs)."""
|
|
315
|
+
t0, ms0, bpm = self._find(self.bpm, tick)
|
|
316
|
+
v = (float(tick - t0) * 60000.0) / float(f32(bpm * f32(480.0))) + float(ms0)
|
|
317
|
+
return int(math.floor(v))
|
|
318
|
+
|
|
319
|
+
def bar_position(self, tick: int) -> BarPos:
|
|
320
|
+
"""TickToBarPosition."""
|
|
321
|
+
t0, bar0, tpm, _, _ = self._find(self.sig, tick)
|
|
322
|
+
q = int((tick - t0) / tpm) if tpm != 0 else 0
|
|
323
|
+
r = (tick - t0) - q * tpm
|
|
324
|
+
return BarPos(bar0 + q, r, tpm, f32(f32(r) / f32(tpm)))
|
|
325
|
+
|
|
326
|
+
def bar_head_tick(self, bar: int) -> int:
|
|
327
|
+
"""BarHeadTick."""
|
|
328
|
+
seg = self.sig[0]
|
|
329
|
+
for s in reversed(self.sig):
|
|
330
|
+
if s[1] <= bar:
|
|
331
|
+
seg = s
|
|
332
|
+
break
|
|
333
|
+
return seg[0] + (bar - seg[1]) * seg[2]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# --------------------------------------------------------------------------- runtime positions/events
|
|
337
|
+
@dataclass
|
|
338
|
+
class Pos:
|
|
339
|
+
"""MusicScorePosition."""
|
|
340
|
+
bar: int
|
|
341
|
+
rhythm: int
|
|
342
|
+
unit: int
|
|
343
|
+
progress: np.float32
|
|
344
|
+
ms: int
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def key(self) -> np.float32:
|
|
348
|
+
return f32(self.progress + f32(self.bar))
|
|
349
|
+
|
|
350
|
+
def before(self, bar: int, progress) -> bool:
|
|
351
|
+
return self.bar < bar or (self.bar == bar and self.progress < progress)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def make_position(tick: int, tc: TickConverter) -> Pos:
|
|
355
|
+
"""SsMusicScoreConverter.MakePosition."""
|
|
356
|
+
bp = tc.bar_position(tick)
|
|
357
|
+
return Pos(bp.bar, bp.rhythm, bp.unit, bp.progress, tc.time_ms(tick))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def time_ms_from_bar(bar: int, progress, bpm_events: list, bar_events: list) -> int:
|
|
361
|
+
"""MusicScoreUtility.GetTimeMsFromBar (all float32, floor)."""
|
|
362
|
+
progress = f32(progress)
|
|
363
|
+
ref = Pos(0, 0, 0, f32(0.0), 0)
|
|
364
|
+
beats = f32(4.0)
|
|
365
|
+
for value, p in bar_events: # BarChangeEvent (ChangeBar, position)
|
|
366
|
+
if p.before(bar, progress):
|
|
367
|
+
beats = value
|
|
368
|
+
if p.ms > ref.ms:
|
|
369
|
+
ref = p
|
|
370
|
+
bpm = f32(160.0)
|
|
371
|
+
for value, p in bpm_events: # BPMChangeEvent (ChangeBPM, position)
|
|
372
|
+
if p.before(bar, progress):
|
|
373
|
+
bpm = value
|
|
374
|
+
if p.ms > ref.ms:
|
|
375
|
+
ref = p
|
|
376
|
+
spb = f32(f32(beats * f32(60.0)) / bpm)
|
|
377
|
+
x = f32(f32(spb * f32(bar - ref.bar)) + f32(spb * f32(progress - ref.progress)))
|
|
378
|
+
x = f32(x * f32(1000.0))
|
|
379
|
+
return ref.ms + int(math.floor(float(x)))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def bar_rhythm(bar: int, bar_events: list) -> np.float32:
|
|
383
|
+
"""MusicScoreUtility.GetBarRhythm."""
|
|
384
|
+
v = f32(4.0)
|
|
385
|
+
for value, p in bar_events:
|
|
386
|
+
if bar < p.bar:
|
|
387
|
+
return v
|
|
388
|
+
v = value
|
|
389
|
+
return v
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def bpm_at_ms(ms: int, bpm_events: list) -> np.float32:
|
|
393
|
+
"""MusicScoreNoteCreator.GetBpmAtTimeMs."""
|
|
394
|
+
v = f32(160.0)
|
|
395
|
+
for value, p in bpm_events:
|
|
396
|
+
if p.ms <= ms:
|
|
397
|
+
v = value
|
|
398
|
+
return v
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def same_position(a: Pos, b: Pos) -> bool:
|
|
402
|
+
"""MusicScoreNoteCreator.IsSamePosition / MusicScoreUtility.EqualMusicInfoPosition."""
|
|
403
|
+
return a.bar == b.bar and mathf_approximately(a.progress, b.progress)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def ease(t, kind: int) -> np.float32:
|
|
407
|
+
"""LiveCalculator.GetNoteLineTypeEasing."""
|
|
408
|
+
t = f32(t)
|
|
409
|
+
if kind == 0:
|
|
410
|
+
return t
|
|
411
|
+
if kind == 2:
|
|
412
|
+
return f32(t * t)
|
|
413
|
+
if kind == 1:
|
|
414
|
+
return f32(f32(f32(2.0) - t) * t)
|
|
415
|
+
raise ValueError(f"noteLineEaseType {kind}")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def convert_ease(e: int) -> int:
|
|
419
|
+
"""SsMusicScoreConverter.ConvertEase: chart in(1) -> 2, out(2) -> 1, linear -> 0."""
|
|
420
|
+
return 2 if e == 1 else (1 if e == 2 else 0)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def flick_direction(n: SsNote) -> int:
|
|
424
|
+
"""ResolveFlickDir: left -> Left(1), right -> Right(2), up/down -> Normal(0)."""
|
|
425
|
+
return 1 if n.dir == 1 else (2 if n.dir == 2 else 0)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def mirror_direction(d: int, mirror: bool) -> int:
|
|
429
|
+
"""ApplyMirrorDirection."""
|
|
430
|
+
if not mirror:
|
|
431
|
+
return d
|
|
432
|
+
return 2 if d == 1 else (1 if d == 2 else 0)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def apply_mirror(lane, width, mirror: bool) -> np.float32:
|
|
436
|
+
"""ApplyMirror: 24 - (lane + 0) - width when mirrored, else lane + 0."""
|
|
437
|
+
if mirror:
|
|
438
|
+
return f32(f32(f32(24.0) - f32(lane + f32(0.0))) - width)
|
|
439
|
+
return f32(lane + f32(0.0))
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def overlap_key(t: int, pos, size) -> tuple:
|
|
443
|
+
"""MakeOverlapKey: (t, Math.Round(pos), Math.Round(size))."""
|
|
444
|
+
return (t, net_round_int(float(pos)), net_round_int(float(size)))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# --------------------------------------------------------------------------- NoteInfoData
|
|
448
|
+
@dataclass
|
|
449
|
+
class NoteInfo:
|
|
450
|
+
bar: int
|
|
451
|
+
rhythm: int
|
|
452
|
+
unit: int
|
|
453
|
+
progress: np.float32
|
|
454
|
+
lane: int
|
|
455
|
+
lane_f: np.float32
|
|
456
|
+
width: int
|
|
457
|
+
width_f: np.float32
|
|
458
|
+
op: int
|
|
459
|
+
slide_along: bool = False
|
|
460
|
+
crit: bool = False
|
|
461
|
+
direction: int = 0
|
|
462
|
+
ease: int = 0
|
|
463
|
+
ease_r: int = 0
|
|
464
|
+
lines: list = field(default_factory=list) # LineIndexList (line *indices*, 0..19)
|
|
465
|
+
src: tuple = ()
|
|
466
|
+
tick: int = 0
|
|
467
|
+
visible: bool = True
|
|
468
|
+
alpha: int = 0
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def key(self) -> np.float32:
|
|
472
|
+
return f32(self.progress + f32(self.bar))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class LineIndexAssigner:
|
|
476
|
+
"""SsMusicScoreConverter.LineIndexAssigner (Acquire, Register)."""
|
|
477
|
+
|
|
478
|
+
def __init__(self, max_lines: int = MAX_LINES):
|
|
479
|
+
self.in_use: list[tuple[int, int]] = []
|
|
480
|
+
self.max = max_lines
|
|
481
|
+
|
|
482
|
+
def acquire(self, start_tick: int) -> int:
|
|
483
|
+
for i in range(len(self.in_use) - 1, -1, -1):
|
|
484
|
+
if self.in_use[i][1] < start_tick:
|
|
485
|
+
del self.in_use[i]
|
|
486
|
+
used = {i for i, _ in self.in_use}
|
|
487
|
+
for i in range(self.max):
|
|
488
|
+
if i not in used:
|
|
489
|
+
return i
|
|
490
|
+
raise ValueError(f"ss lineIndex {self.max} startTick {start_tick}")
|
|
491
|
+
|
|
492
|
+
def register(self, idx: int, end_tick: int) -> None:
|
|
493
|
+
self.in_use.append((idx, end_tick))
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _single_info(n: SsNote, tc: TickConverter, mirror: bool) -> NoteInfo:
|
|
497
|
+
"""BuildSingleNoteInfo."""
|
|
498
|
+
bp = tc.bar_position(n.t)
|
|
499
|
+
lane_f = apply_mirror(n.pos, n.size, mirror)
|
|
500
|
+
lane = net_round_int(float(lane_f))
|
|
501
|
+
width = max(1, net_round_int(float(n.size)))
|
|
502
|
+
if n.type == 1:
|
|
503
|
+
op, d = 40, flick_direction(n)
|
|
504
|
+
elif n.type == 2:
|
|
505
|
+
op, d = 60, 0
|
|
506
|
+
else:
|
|
507
|
+
op, d = 1, 0
|
|
508
|
+
return NoteInfo(bp.bar, bp.rhythm, bp.unit, bp.progress, lane, lane_f, width, n.size, op,
|
|
509
|
+
False, n.crit, mirror_direction(d, mirror), 0, 0, [], n.src, n.t, n.visible, n.alpha)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _long_node_op(nodes: list, i: int) -> int:
|
|
513
|
+
"""ResolveLongNodeOperateType."""
|
|
514
|
+
n = nodes[i]
|
|
515
|
+
if i == 0:
|
|
516
|
+
if not n.visible:
|
|
517
|
+
return 80
|
|
518
|
+
return 41 if n.type == 1 else (61 if n.type == 2 else 20)
|
|
519
|
+
if i == len(nodes) - 1:
|
|
520
|
+
if not n.visible:
|
|
521
|
+
return 82
|
|
522
|
+
return 42 if n.type == 1 else (62 if n.type == 2 else 22)
|
|
523
|
+
if n.pos_auto:
|
|
524
|
+
return 21
|
|
525
|
+
if not n.visible:
|
|
526
|
+
return 122
|
|
527
|
+
return 63 if n.type == 2 else 21
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _guide_node_op(nodes: list, i: int, guide_map: dict | None) -> int:
|
|
531
|
+
"""ResolveGuideNodeOperateType (its constant table: tap 101, flick 102, trace 104)."""
|
|
532
|
+
n = nodes[i]
|
|
533
|
+
if i == 0:
|
|
534
|
+
if guide_map is not None and not n.pos_auto:
|
|
535
|
+
s = guide_map.get(overlap_key(n.t, n.pos, n.size))
|
|
536
|
+
if s is not None and s.type < 3:
|
|
537
|
+
return (101, 102, 104)[s.type]
|
|
538
|
+
return 100
|
|
539
|
+
if i == len(nodes) - 1:
|
|
540
|
+
if not n.visible:
|
|
541
|
+
return 103
|
|
542
|
+
return 105 if n.type == 2 else 103
|
|
543
|
+
if n.pos_auto:
|
|
544
|
+
return 63
|
|
545
|
+
return 122 if not n.visible else 63
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _line_infos(chains: list, is_long: bool, tc: TickConverter, mirror: bool, out: list,
|
|
549
|
+
guide_map: dict | None, assigner: LineIndexAssigner) -> None:
|
|
550
|
+
"""BuildLongOrGuideNoteInfos."""
|
|
551
|
+
for _, chain in chains:
|
|
552
|
+
nodes = chain.node
|
|
553
|
+
N = len(nodes)
|
|
554
|
+
line_idx = assigner.acquire(nodes[0].t)
|
|
555
|
+
end_tick = nodes[-1].t
|
|
556
|
+
for i, nd in enumerate(nodes):
|
|
557
|
+
op = _long_node_op(nodes, i) if is_long else _guide_node_op(nodes, i, guide_map)
|
|
558
|
+
if not nd.pos_auto:
|
|
559
|
+
pos, size = nd.pos, nd.size
|
|
560
|
+
elif i == 0 or i >= N - 1:
|
|
561
|
+
pos, size = nodes[0].pos, nodes[0].size
|
|
562
|
+
else:
|
|
563
|
+
j = i
|
|
564
|
+
while True: # nearest non-auto node in [1, i-1], else node 0
|
|
565
|
+
if j < 2:
|
|
566
|
+
j = 0
|
|
567
|
+
break
|
|
568
|
+
j -= 1
|
|
569
|
+
if not nodes[j].pos_auto:
|
|
570
|
+
break
|
|
571
|
+
k = i + 1
|
|
572
|
+
while k < N - 1 and nodes[k].pos_auto: # nearest non-auto node in [i+1, N-2], else N-1
|
|
573
|
+
k += 1
|
|
574
|
+
a, b = nodes[j], nodes[k]
|
|
575
|
+
span = b.t - a.t
|
|
576
|
+
prog = f32(0.0) if span < 1 else f32(f32(nd.t - a.t) / f32(span))
|
|
577
|
+
el = ease(prog, convert_ease(a.ease_l))
|
|
578
|
+
er = ease(prog, convert_ease(a.ease_r))
|
|
579
|
+
pos = f32(a.pos + f32(el * f32(b.pos - a.pos)))
|
|
580
|
+
a_r = f32(a.pos + a.size)
|
|
581
|
+
right = f32(a_r + f32(er * f32(f32(b.pos + b.size) - a_r)))
|
|
582
|
+
size = f32(right - pos)
|
|
583
|
+
bp = tc.bar_position(nd.t)
|
|
584
|
+
lane_f = apply_mirror(pos, size, mirror)
|
|
585
|
+
lane = net_round_int(float(lane_f))
|
|
586
|
+
width = max(1, net_round_int(float(size)))
|
|
587
|
+
dir_src = nd
|
|
588
|
+
if guide_map is not None and i == 0 and not is_long and not nd.pos_auto:
|
|
589
|
+
s = guide_map.get(overlap_key(nd.t, nd.pos, nd.size))
|
|
590
|
+
if s is not None:
|
|
591
|
+
dir_src = s
|
|
592
|
+
d = flick_direction(dir_src) if dir_src.type == 1 else 0
|
|
593
|
+
out.append(NoteInfo(bp.bar, bp.rhythm, bp.unit, bp.progress, lane, lane_f, width, size, op,
|
|
594
|
+
nd.pos_auto, nd.crit, mirror_direction(d, mirror),
|
|
595
|
+
convert_ease(nd.ease_l), convert_ease(nd.ease_r), [line_idx], nd.src,
|
|
596
|
+
nd.t, nd.visible, nd.alpha))
|
|
597
|
+
assigner.register(line_idx, end_tick)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _build_note_infos(notes: list, tc: TickConverter, mirror: bool) -> list[NoteInfo]:
|
|
601
|
+
"""BuildNoteInfoList."""
|
|
602
|
+
guide_starts = set()
|
|
603
|
+
for n in notes:
|
|
604
|
+
if n.type == 4 and n.node:
|
|
605
|
+
first = n.node[0]
|
|
606
|
+
if not first.pos_auto:
|
|
607
|
+
guide_starts.add(overlap_key(first.t, first.pos, first.size))
|
|
608
|
+
guide_map: dict = {}
|
|
609
|
+
merged: set[int] = set()
|
|
610
|
+
for i, n in enumerate(notes):
|
|
611
|
+
if n.type < 3:
|
|
612
|
+
k = overlap_key(n.t, n.pos, n.size)
|
|
613
|
+
if k in guide_starts and k not in guide_map:
|
|
614
|
+
guide_map[k] = n
|
|
615
|
+
merged.add(i)
|
|
616
|
+
out: list[NoteInfo] = []
|
|
617
|
+
for i, n in enumerate(notes):
|
|
618
|
+
if i not in merged and n.type < 3:
|
|
619
|
+
out.append(_single_info(n, tc, mirror))
|
|
620
|
+
order = lambda it: (it[1].node[0].t, it[0]) # <BuildNoteInfoList>b__7_0/7_1
|
|
621
|
+
longs = sorted(((i, n) for i, n in enumerate(notes) if n.type == 3 and n.node and len(n.node) > 1), key=order)
|
|
622
|
+
assigner = LineIndexAssigner(MAX_LINES)
|
|
623
|
+
_line_infos(longs, True, tc, mirror, out, None, assigner)
|
|
624
|
+
guides = sorted(((i, n) for i, n in enumerate(notes) if n.type == 4 and n.node and len(n.node) > 1), key=order)
|
|
625
|
+
_line_infos(guides, False, tc, mirror, out, guide_map, assigner)
|
|
626
|
+
return out
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
MERGEABLE = {20, 22, 41, 42, 61, 62, 80, 82, 100, 101, 102, 103, 104, 105} # IsMergeableSlideEndpoint
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _info_dictionary(infos: list[NoteInfo], log: list) -> dict:
|
|
633
|
+
"""BuildNoteInfoDictionary + TryMergeSlideEndpoint."""
|
|
634
|
+
d: dict = {}
|
|
635
|
+
for n in infos:
|
|
636
|
+
lanes = d.setdefault(n.key, {})
|
|
637
|
+
lst = lanes.setdefault(n.lane, [])
|
|
638
|
+
if n.op in MERGEABLE and n.lines:
|
|
639
|
+
hit = next((e for e in lst if e.op == n.op and e.width == n.width and e.crit == n.crit
|
|
640
|
+
and e.direction == n.direction and e.ease == n.ease and e.ease_r == n.ease_r), None)
|
|
641
|
+
if hit is not None:
|
|
642
|
+
hit.lines.extend(n.lines)
|
|
643
|
+
continue
|
|
644
|
+
if is_judgement_note(n.op):
|
|
645
|
+
cnt = sum(1 for e in lst if is_judgement_note(e.op))
|
|
646
|
+
if cnt > 2:
|
|
647
|
+
log.append(f"{3} bar {n.bar} barProgress {float(n.progress)} lane {n.lane} op {OP_NAMES[n.op]}")
|
|
648
|
+
lst.append(n)
|
|
649
|
+
return d
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _priority(op: int) -> int:
|
|
653
|
+
"""<CreateNoteDictionary>b__23_2 (sort priority)."""
|
|
654
|
+
if op in (20, 41, 61, 80, 100, 101, 102, 104):
|
|
655
|
+
return 8
|
|
656
|
+
if op == 122:
|
|
657
|
+
return 9
|
|
658
|
+
return 10
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# --------------------------------------------------------------------------- MusicScoreNoteCreator
|
|
662
|
+
@dataclass(eq=False)
|
|
663
|
+
class Note:
|
|
664
|
+
id: int
|
|
665
|
+
pos: Pos
|
|
666
|
+
op: int
|
|
667
|
+
lane_start: int
|
|
668
|
+
lane_end: int
|
|
669
|
+
lane_start_f: np.float32
|
|
670
|
+
lane_end_f: np.float32
|
|
671
|
+
width: np.float32
|
|
672
|
+
slide_along: bool
|
|
673
|
+
crit: bool
|
|
674
|
+
direction: int
|
|
675
|
+
line_ids: list
|
|
676
|
+
ease: int
|
|
677
|
+
ease_r: int
|
|
678
|
+
offset_type: int
|
|
679
|
+
pair_id: int = 0
|
|
680
|
+
fever: int | None = None
|
|
681
|
+
info: NoteInfo | None = None
|
|
682
|
+
# line bookkeeping (SlideBeginNote / GuideBeginNote / SlideEndNote)
|
|
683
|
+
view_notes: list = field(default_factory=list)
|
|
684
|
+
combo_notes: list = field(default_factory=list)
|
|
685
|
+
begin_notes: list = field(default_factory=list)
|
|
686
|
+
end_combo: list = field(default_factory=list)
|
|
687
|
+
begin_ref: "Note | None" = None
|
|
688
|
+
end_ref: "Note | None" = None
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
class _Creator:
|
|
692
|
+
"""MusicScoreNoteCreator (Initialize, Create)."""
|
|
693
|
+
|
|
694
|
+
def __init__(self, start_id: int, bpm_events, bar_events, fevers):
|
|
695
|
+
self.bpm_events, self.bar_events, self.fevers = bpm_events, bar_events, fevers
|
|
696
|
+
self.cur_id = start_id
|
|
697
|
+
self.cur_line = start_id
|
|
698
|
+
self.cur_guide_line = start_id
|
|
699
|
+
self.pair_tmp: Note | None = None
|
|
700
|
+
self.begin_by_line: dict[int, Note] = {}
|
|
701
|
+
self.line_arr = [-1] * MAX_LINES
|
|
702
|
+
self.guide_arr = [-1] * MAX_LINES
|
|
703
|
+
self.log: list[str] = []
|
|
704
|
+
|
|
705
|
+
def _note(self, info: NoteInfo, pos: Pos, op: int, line_ids: list, offset: int) -> Note:
|
|
706
|
+
# NoteBase(id, pos, noteInfo, lineIds, offsetType)
|
|
707
|
+
return Note(self.cur_id, pos, op, info.lane, info.lane + info.width - 1, info.lane_f,
|
|
708
|
+
f32(f32(info.lane_f + info.width_f) - f32(1.0)), info.width_f, info.slide_along,
|
|
709
|
+
info.crit, info.direction, line_ids, info.ease, info.ease_r, offset, info=info)
|
|
710
|
+
|
|
711
|
+
def create(self, info: NoteInfo) -> Note | None:
|
|
712
|
+
ms = time_ms_from_bar(info.bar, info.progress, self.bpm_events, self.bar_events)
|
|
713
|
+
pos = Pos(info.bar, info.rhythm, info.unit, info.progress, ms)
|
|
714
|
+
self.cur_id += 1
|
|
715
|
+
op = info.op
|
|
716
|
+
crit = info.crit
|
|
717
|
+
if op == 1:
|
|
718
|
+
note = self._note(info, pos, op, [], 8 if crit else 0)
|
|
719
|
+
elif op in (20, 41, 61, 80):
|
|
720
|
+
ids = []
|
|
721
|
+
for li in info.lines:
|
|
722
|
+
self.cur_line += 1
|
|
723
|
+
ids.append(self.cur_line)
|
|
724
|
+
self.line_arr[li] = self.cur_line
|
|
725
|
+
offset = {20: (9 if crit else 2), 41: 4, 61: 5, 80: 5}[op] # Create*Begin*Note ctor argument
|
|
726
|
+
note = self._note(info, pos, op, ids, offset)
|
|
727
|
+
for lid in ids:
|
|
728
|
+
self.begin_by_line[lid] = note
|
|
729
|
+
elif op in (21, 63):
|
|
730
|
+
arr = self.line_arr if op == 21 else self.guide_arr
|
|
731
|
+
lid = arr[info.lines[0]]
|
|
732
|
+
note = self._note(info, pos, op, [lid], 1 if op == 21 else 5)
|
|
733
|
+
b = self.begin_by_line.get(lid)
|
|
734
|
+
if b is not None:
|
|
735
|
+
b.view_notes.append(note)
|
|
736
|
+
elif op in (22, 42, 62, 82):
|
|
737
|
+
ids = []
|
|
738
|
+
for li in info.lines:
|
|
739
|
+
ids.append(self.line_arr[li])
|
|
740
|
+
self.line_arr[li] = -1
|
|
741
|
+
note = self._note(info, pos, op, ids, {22: 3, 42: 4, 62: 5, 82: 5}[op])
|
|
742
|
+
self._end_line(note)
|
|
743
|
+
elif op == 40:
|
|
744
|
+
note = self._note(info, pos, op, [], 4)
|
|
745
|
+
elif op == 60:
|
|
746
|
+
note = self._note(info, pos, op, [], 5)
|
|
747
|
+
elif op in (100, 101, 102, 104):
|
|
748
|
+
ids = []
|
|
749
|
+
for li in info.lines:
|
|
750
|
+
lid = self.cur_guide_line + 10001
|
|
751
|
+
self.cur_guide_line += 1
|
|
752
|
+
ids.append(lid)
|
|
753
|
+
self.guide_arr[li] = lid
|
|
754
|
+
note = self._note(info, pos, op, ids, 5 if op == 104 else 0)
|
|
755
|
+
for lid in ids:
|
|
756
|
+
self.begin_by_line[lid] = note
|
|
757
|
+
elif op in (103, 105):
|
|
758
|
+
ids = []
|
|
759
|
+
for li in info.lines:
|
|
760
|
+
ids.append(self.guide_arr[li])
|
|
761
|
+
self.guide_arr[li] = -1
|
|
762
|
+
note = self._note(info, pos, op, ids, 5 if op == 105 else 0)
|
|
763
|
+
self._end_line(note)
|
|
764
|
+
elif op == 122:
|
|
765
|
+
lid = self.line_arr[info.lines[0]]
|
|
766
|
+
if lid < 1:
|
|
767
|
+
lid = self.guide_arr[info.lines[0]]
|
|
768
|
+
if lid < 1:
|
|
769
|
+
self.log.append(f"noteId {self.cur_id} pos {pos}")
|
|
770
|
+
return None
|
|
771
|
+
note = self._note(info, pos, op, [lid], 0)
|
|
772
|
+
b = self.begin_by_line.get(lid)
|
|
773
|
+
if b is not None:
|
|
774
|
+
b.view_notes.append(note)
|
|
775
|
+
else:
|
|
776
|
+
self.log.append(f"noteId {self.cur_id} type {OP_NAMES.get(op, op)}")
|
|
777
|
+
return None
|
|
778
|
+
if op in (1, 20, 22, 40, 41, 42): # IsPairNoteType
|
|
779
|
+
t = self.pair_tmp
|
|
780
|
+
if t is not None and same_position(t.pos, note.pos): # TrySetPairNoteId
|
|
781
|
+
note.pair_id, t.pair_id = t.id, note.id
|
|
782
|
+
self.pair_tmp = note
|
|
783
|
+
note.fever = self._fever(note.pos.ms)
|
|
784
|
+
return note
|
|
785
|
+
|
|
786
|
+
def _fever(self, ms: int) -> int | None:
|
|
787
|
+
"""IsFeverTargetNote: first fever with start.ms <= ms <= end.ms."""
|
|
788
|
+
for idx, start, end in self.fevers:
|
|
789
|
+
if start.ms <= ms <= end.ms:
|
|
790
|
+
return idx
|
|
791
|
+
return None
|
|
792
|
+
|
|
793
|
+
def _end_line(self, end: Note) -> None:
|
|
794
|
+
"""TrySetSlideNoteId, then SlideEndNote.AddBeginNote."""
|
|
795
|
+
begins = []
|
|
796
|
+
ok = True
|
|
797
|
+
for lid in end.line_ids:
|
|
798
|
+
begin = self.begin_by_line.get(lid)
|
|
799
|
+
if begin is None:
|
|
800
|
+
ok = False
|
|
801
|
+
break
|
|
802
|
+
begins.append(begin)
|
|
803
|
+
notes = [x for x in begin.view_notes if lid in x.line_ids]
|
|
804
|
+
notes.sort(key=lambda x: (x.pos.bar, float(x.pos.progress))) # OrderBy(Bar).ThenBy(BarProgress)
|
|
805
|
+
notes.append(end)
|
|
806
|
+
cur = next(x for x in notes if not x.slide_along) # First(!SlideAlong)
|
|
807
|
+
start_pos, end_pos = begin.pos, end.pos
|
|
808
|
+
mids = [x.pos for x in notes if x is not end and is_judgement_note(x.op)] # <>c__DisplayClass43_0.b__4
|
|
809
|
+
beats = list(_beats_between(start_pos, end_pos, mids, self.bpm_events, self.bar_events))
|
|
810
|
+
prev = begin
|
|
811
|
+
walk = 0
|
|
812
|
+
for k, beat in enumerate(beats):
|
|
813
|
+
if any(is_judgement_note(x.op) and same_position(x.pos, beat) for x in notes): # b__6
|
|
814
|
+
continue
|
|
815
|
+
window = 15000.0 / float(bpm_at_ms(beat.ms, self.bpm_events))
|
|
816
|
+
skip = any(float(mp.ms) - window <= float(beat.ms) and beat.ms < mp.ms for mp in mids)
|
|
817
|
+
if k == 0 and window > float(beat.ms - start_pos.ms):
|
|
818
|
+
skip = True
|
|
819
|
+
if k == len(beats) - 1 and window > float(end_pos.ms - beat.ms):
|
|
820
|
+
skip = True
|
|
821
|
+
while cur.pos.key < beat.key: # walk to the enclosing segment
|
|
822
|
+
nxt = notes[walk]
|
|
823
|
+
walk += 1
|
|
824
|
+
if nxt.id != cur.id and not nxt.slide_along:
|
|
825
|
+
prev, cur = cur, nxt
|
|
826
|
+
if walk >= len(notes):
|
|
827
|
+
break
|
|
828
|
+
ls, le = lane_position(beat, prev, cur)
|
|
829
|
+
cid = lid + k * 10000 + 10000
|
|
830
|
+
# SlideComboNote..ctor: LaneStart/EndIndex = floor (not truncation), Width = end - start + 1
|
|
831
|
+
combo = Note(cid, beat, 121 if skip else 120, int(math.floor(float(ls))), int(math.floor(float(le))),
|
|
832
|
+
ls, le, f32(f32(le - ls) + f32(1.0)), False, False, 0, [lid], 0, 0, 1)
|
|
833
|
+
combo.begin_ref, combo.end_ref = begin, end
|
|
834
|
+
combo.fever = self._fever(beat.ms)
|
|
835
|
+
if begin.op in SLIDE_BEGIN_CLASS: # GuideBeginNote.AddCombo is empty
|
|
836
|
+
begin.combo_notes.append(combo)
|
|
837
|
+
self.begin_by_line.pop(lid, None)
|
|
838
|
+
if ok:
|
|
839
|
+
for b in begins:
|
|
840
|
+
end.begin_notes.append(b)
|
|
841
|
+
if end.op in SLIDE_END_CLASS and b.op in SLIDE_BEGIN_CLASS:
|
|
842
|
+
end.end_combo.extend(b.combo_notes)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _beats_between(start: Pos, end: Pos, mids: list, bpm_events, bar_events):
|
|
846
|
+
"""GetBeatsBetweenNotes d__44: eighth notes of every [boundary, next boundary) segment."""
|
|
847
|
+
bounds = [start, *mids, end]
|
|
848
|
+
for s in range(len(bounds) - 1):
|
|
849
|
+
yield from _eighths(bounds[s], bounds[s + 1], bpm_events, bar_events)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _eighths(seg_start: Pos, seg_end: Pos, bpm_events, bar_events):
|
|
853
|
+
"""GetRelativeEighthNotePositions d__45 (double accumulator, restarts at the segment start)."""
|
|
854
|
+
bar = seg_start.bar
|
|
855
|
+
prog = float(seg_start.progress)
|
|
856
|
+
r = bar_rhythm(bar, bar_events)
|
|
857
|
+
step = 1.0 / (float(r) + float(r))
|
|
858
|
+
while True:
|
|
859
|
+
prog += step
|
|
860
|
+
if prog >= 1.0:
|
|
861
|
+
bar += 1
|
|
862
|
+
prog -= 1.0
|
|
863
|
+
r = bar_rhythm(bar, bar_events)
|
|
864
|
+
step = 1.0 / (float(r) + float(r))
|
|
865
|
+
fp = f32(prog)
|
|
866
|
+
ms = time_ms_from_bar(bar, fp, bpm_events, bar_events)
|
|
867
|
+
if seg_end.ms <= ms:
|
|
868
|
+
return
|
|
869
|
+
r2 = bar_rhythm(bar, bar_events)
|
|
870
|
+
unit = int(f32(r2 + r2))
|
|
871
|
+
rhythm = net_round_int(float(f32(fp * f32(unit))))
|
|
872
|
+
yield Pos(bar, rhythm, unit, fp, ms)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def lane_position(pos: Pos, a: Note, b: Note) -> tuple:
|
|
876
|
+
"""MusicScoreUtility.GetLanePosition (one ease, a.LineEaseType, for both edges)."""
|
|
877
|
+
span = f32(b.pos.key - a.pos.key)
|
|
878
|
+
if span <= 0.0:
|
|
879
|
+
e = ease(f32(1.0), a.ease)
|
|
880
|
+
else:
|
|
881
|
+
e = ease(f32(f32(pos.key - a.pos.key) / span), a.ease)
|
|
882
|
+
e = min(max(e, f32(0.0)), f32(1.0))
|
|
883
|
+
return (f32(a.lane_start_f + f32(e * f32(b.lane_start_f - a.lane_start_f))),
|
|
884
|
+
f32(a.lane_end_f + f32(e * f32(b.lane_end_f - a.lane_end_f))))
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
# --------------------------------------------------------------------------- convert
|
|
888
|
+
@dataclass
|
|
889
|
+
class RuntimeScore:
|
|
890
|
+
"""The runtime score SsMusicScoreConverter.Load builds, before it is written as JSON."""
|
|
891
|
+
by_key: dict # position key -> notes, keys in the order CreateNoteDictionary adds them
|
|
892
|
+
infos: list # NoteInfoData
|
|
893
|
+
tc: TickConverter
|
|
894
|
+
events: SsEvents
|
|
895
|
+
bpm_events: list # (bpm, Pos), BpmChangeEventList
|
|
896
|
+
bar_events: list # (beats per bar, Pos), BarChangeEventList
|
|
897
|
+
fevers: list # (index, start Pos, end Pos) sorted by start, FeverList
|
|
898
|
+
skills: list # (index, Pos) in chart order, SkillEventList
|
|
899
|
+
calls: list # (Pos, rhythms), CallChangeEventList
|
|
900
|
+
bar_line_ms: list # BarLineTimeMsList
|
|
901
|
+
log: list
|
|
902
|
+
|
|
903
|
+
@property
|
|
904
|
+
def notes(self) -> list:
|
|
905
|
+
"""MusicScore.NoteDictionary in enumeration order. SetNoteList flattens the position dictionary in the
|
|
906
|
+
order its keys were added, then list order; a slide combo tick on a position no other note has is added
|
|
907
|
+
with its slide end, so it comes after the end's position."""
|
|
908
|
+
return [n for lst in self.by_key.values() for n in lst]
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def runtime_score(root: dict, mirror: bool = False, start_note_id: int = 0) -> RuntimeScore:
|
|
912
|
+
"""SsMusicScoreConverter.Load up to MusicScore.SetNoteList."""
|
|
913
|
+
ev, notes = parse(root)
|
|
914
|
+
tc = TickConverter(ev.sig, ev.bpm)
|
|
915
|
+
infos = _build_note_infos(notes, tc, mirror)
|
|
916
|
+
|
|
917
|
+
bpm_events = [(b, make_position(t, tc)) for t, b in sorted(ev.bpm, key=lambda x: x[0])] # BuildBpmEvents
|
|
918
|
+
bar_events = [(f32(f32(f32(n) * f32(4.0)) / f32(d)), make_position(t, tc)) # BuildBarEvents
|
|
919
|
+
for t, n, d in sorted(ev.sig, key=lambda x: x[0])]
|
|
920
|
+
fevers = [(i, make_position(a, tc), make_position(b, tc)) # BuildFeverList
|
|
921
|
+
for i, (a, b) in enumerate(sorted(ev.fever, key=lambda x: x[0]))]
|
|
922
|
+
skills = [(i, make_position(t, tc)) for i, t in enumerate(ev.skill)] # BuildSkillList
|
|
923
|
+
calls = [] # BuildCallList
|
|
924
|
+
for t, timing in sorted(ev.call, key=lambda x: x[0]):
|
|
925
|
+
n = len(timing)
|
|
926
|
+
calls.append((make_position(t, tc), [float(f32(f32(i + 1.0) / f32(n))) for i, v in enumerate(timing) if v == 1]))
|
|
927
|
+
|
|
928
|
+
max_bar = max((n.bar for n in infos), default=-1) # <Load>b__5_0
|
|
929
|
+
bar_line_ms = [tc.time_ms(tc.bar_head_tick(b)) for b in range(max_bar + 1)]
|
|
930
|
+
|
|
931
|
+
log: list[str] = []
|
|
932
|
+
idict = _info_dictionary(infos, log)
|
|
933
|
+
creator = _Creator(start_note_id, bpm_events, bar_events, fevers)
|
|
934
|
+
by_key: dict = {} # CreateNoteDictionary
|
|
935
|
+
for key in sorted(idict):
|
|
936
|
+
flat = [x for lst in idict[key].values() for x in lst]
|
|
937
|
+
flat.sort(key=lambda x: _priority(x.op))
|
|
938
|
+
for info in flat:
|
|
939
|
+
note = creator.create(info)
|
|
940
|
+
if note is None:
|
|
941
|
+
continue
|
|
942
|
+
by_key.setdefault(note.pos.key, []).append(note)
|
|
943
|
+
if note.op in SLIDE_END_CLASS and note.end_combo:
|
|
944
|
+
for c in note.end_combo:
|
|
945
|
+
lst = by_key.setdefault(c.pos.key, [])
|
|
946
|
+
if c not in lst:
|
|
947
|
+
lst.append(c)
|
|
948
|
+
log += creator.log
|
|
949
|
+
return RuntimeScore(by_key, infos, tc, ev, bpm_events, bar_events, fevers, skills, calls, bar_line_ms, log)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def convert(root: dict, mirror: bool = False, start_note_id: int = 0) -> dict:
|
|
953
|
+
"""SsMusicScoreConverter.Load -> plain dict of the runtime score, notes in position order."""
|
|
954
|
+
s = runtime_score(root, mirror, start_note_id)
|
|
955
|
+
ordered = [n for k in sorted(s.by_key) for n in s.by_key[k]]
|
|
956
|
+
return _to_json(ordered, s.infos, s.tc, s.events, s.bpm_events, s.bar_events, s.fevers, s.skills, s.calls,
|
|
957
|
+
s.bar_line_ms, s.log, mirror, start_note_id)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _pos_json(p: Pos) -> dict:
|
|
961
|
+
return {"bar": p.bar, "rhythm": p.rhythm, "rhythmicUnit": p.unit, "barProgress": float(p.progress), "timeMs": p.ms}
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _to_json(notes, infos, tc, ev, bpm_events, bar_events, fevers, skills, calls, bar_line_ms, log, mirror, start_id):
|
|
965
|
+
out_notes = []
|
|
966
|
+
lines: dict[int, dict] = {}
|
|
967
|
+
for n in notes:
|
|
968
|
+
info = n.info
|
|
969
|
+
rec = {
|
|
970
|
+
"id": n.id, "op": n.op, "opName": OP_NAMES[n.op],
|
|
971
|
+
"timeMs": n.pos.ms, "bar": n.pos.bar, "rhythm": n.pos.rhythm, "rhythmicUnit": n.pos.unit,
|
|
972
|
+
"barProgress": float(n.pos.progress),
|
|
973
|
+
"laneStart": n.lane_start, "laneEnd": n.lane_end,
|
|
974
|
+
"laneStartFloat": float(n.lane_start_f), "laneEndFloat": float(n.lane_end_f), "width": float(n.width),
|
|
975
|
+
"critical": n.crit, "direction": DIRECTION_NAMES[n.direction], "slideAlong": n.slide_along,
|
|
976
|
+
"lineIds": list(n.line_ids), "lineEase": LINE_EASE_NAMES[n.ease], "lineEaseR": LINE_EASE_NAMES[n.ease_r],
|
|
977
|
+
"pairNoteId": n.pair_id, "fever": n.fever,
|
|
978
|
+
"judgement": is_judgement_note(n.op), "flick": is_flick_type(n.op),
|
|
979
|
+
"judgementType": JUDGEMENT_TYPE_NAMES[judgement_type(n.op, n.crit)] if n.op != 123 else None,
|
|
980
|
+
"judgementAreaOffset": OFFSET_TYPE_NAMES[n.offset_type],
|
|
981
|
+
}
|
|
982
|
+
if info is not None:
|
|
983
|
+
rec.update({"tick": info.tick, "laneIndexFloat": float(info.lane_f), "widthFloat": float(info.width_f),
|
|
984
|
+
"laneIndex": info.lane, "laneWidth": info.width, "lineIndices": list(info.lines),
|
|
985
|
+
"visible": info.visible, "alpha": ["none", "in", "out"][info.alpha], "src": list(info.src)})
|
|
986
|
+
else: # SlideComboNote
|
|
987
|
+
rec.update({"lineBeginId": n.begin_ref.id, "lineEndId": n.end_ref.id})
|
|
988
|
+
out_notes.append(rec)
|
|
989
|
+
for lid in n.line_ids:
|
|
990
|
+
ln = lines.setdefault(lid, {"lineId": lid, "type": "guide" if lid > start_id + 10000 else "long",
|
|
991
|
+
"noteIds": [], "comboIds": []})
|
|
992
|
+
(ln["comboIds"] if info is None else ln["noteIds"]).append(n.id)
|
|
993
|
+
judged = sum(1 for n in notes if is_judgement_note(n.op))
|
|
994
|
+
return {
|
|
995
|
+
"format": "nnnotes.live-score/1",
|
|
996
|
+
"mirror": mirror, "startNoteId": start_id, "laneCount": LANE_COUNT,
|
|
997
|
+
"judgementNoteCount": judged,
|
|
998
|
+
"counts": {OP_NAMES[k]: v for k, v in sorted(_count_ops(notes).items())},
|
|
999
|
+
"notes": out_notes,
|
|
1000
|
+
"lines": sorted(lines.values(), key=lambda x: x["lineId"]),
|
|
1001
|
+
"bpmChanges": [{"bpm": float(v), **_pos_json(p)} for v, p in bpm_events],
|
|
1002
|
+
"barChanges": [{"beatsPerBar": float(v), **_pos_json(p)} for v, p in bar_events],
|
|
1003
|
+
"tickSegments": {"bpm": [{"startTick": a, "startTimeMs": b, "bpm": float(c)} for a, b, c in tc.bpm],
|
|
1004
|
+
"sig": [{"startTick": a, "startBar": b, "ticksPerMeasure": c, "numerator": d,
|
|
1005
|
+
"denominator": e} for a, b, c, d, e in tc.sig]},
|
|
1006
|
+
"barLineTimeMs": bar_line_ms,
|
|
1007
|
+
"fever": [{"index": i, "start": _pos_json(a), "end": _pos_json(b)} for i, a, b in fevers],
|
|
1008
|
+
"skill": [{"index": i, **_pos_json(p)} for i, p in skills],
|
|
1009
|
+
"call": [{**_pos_json(p), "rhythms": r} for p, r in calls],
|
|
1010
|
+
"lastNoteTimeMs": max((n.pos.ms for n in notes), default=0),
|
|
1011
|
+
"log": log,
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _count_ops(notes) -> dict:
|
|
1016
|
+
c: dict = {}
|
|
1017
|
+
for n in notes:
|
|
1018
|
+
c[n.op] = c.get(n.op, 0) + 1
|
|
1019
|
+
return c
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
# --------------------------------------------------------------------------- extraction
|
|
1023
|
+
def master_table(master_dir: Path, name: str) -> list:
|
|
1024
|
+
"""Rows of a decoded master table (<master_dir>/<name>.json, `_allData`)."""
|
|
1025
|
+
return json.loads((Path(master_dir) / f"{name}.json").read_text(encoding="utf-8"))["_allData"]
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def chart_key(file_name: str) -> str:
|
|
1029
|
+
"""LiveEntry loads `Live/MusicScore/` + MasterLiveMusicScore._musicScoreTextFileName."""
|
|
1030
|
+
return f"Live/MusicScore/{file_name}"
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def fetch_chart(cat, file_name: str) -> bytes:
|
|
1034
|
+
"""Raw TextAsset bytes of a chart (still gzip, as shipped)."""
|
|
1035
|
+
from .unity import load_closure
|
|
1036
|
+
key = chart_key(file_name)
|
|
1037
|
+
name = file_name.rsplit("/", 1)[-1]
|
|
1038
|
+
for p in cat.fetch_key(key):
|
|
1039
|
+
env = load_closure([p])
|
|
1040
|
+
for o in env.objects:
|
|
1041
|
+
if o.type.name == "TextAsset":
|
|
1042
|
+
d = o.read()
|
|
1043
|
+
if d.m_Name != name:
|
|
1044
|
+
continue
|
|
1045
|
+
raw = d.m_Script
|
|
1046
|
+
if isinstance(raw, str):
|
|
1047
|
+
raw = raw.encode("utf-8", "surrogateescape")
|
|
1048
|
+
return bytes(raw)
|
|
1049
|
+
raise KeyError(key)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def music_rows(master_dir: Path, music_id: int) -> dict:
|
|
1053
|
+
"""MasterLiveMusic row, its 4 MasterLiveMusicScore rows, title / band name texts in every language
|
|
1054
|
+
(languages.LANGUAGES) and sound rows."""
|
|
1055
|
+
music = next(r for r in master_table(master_dir, "MasterLiveMusic") if r["_id"] == music_id)
|
|
1056
|
+
scores = {r["_id"]: r for r in master_table(master_dir, "MasterLiveMusicScore")}
|
|
1057
|
+
text = {r["_id"]: r for r in master_table(master_dir, "MasterText")}
|
|
1058
|
+
bands = {r["_id"]: r for r in master_table(master_dir, "MasterBand")}
|
|
1059
|
+
sounds = {r["_id"]: r for r in master_table(master_dir, "MasterSound")}
|
|
1060
|
+
sheets = {r["_id"]: r for r in master_table(master_dir, "MasterSoundCueSheet")}
|
|
1061
|
+
|
|
1062
|
+
def tx(i):
|
|
1063
|
+
r = text.get(i)
|
|
1064
|
+
return None if r is None else {"id": i, **languages.texts(r)}
|
|
1065
|
+
|
|
1066
|
+
def snd(i):
|
|
1067
|
+
r = sounds.get(i)
|
|
1068
|
+
if r is None:
|
|
1069
|
+
return None
|
|
1070
|
+
return {"MasterSound": r, "MasterSoundCueSheet": sheets.get(r["_soundCueSheetID"])}
|
|
1071
|
+
|
|
1072
|
+
return {
|
|
1073
|
+
"MasterLiveMusic": music,
|
|
1074
|
+
"MasterLiveMusicScore": {d: scores.get(music[f"_{d}ID"]) for d in DIFFICULTIES},
|
|
1075
|
+
"title": tx(music["_titleTextID"]),
|
|
1076
|
+
"bands": [{"MasterBand": bands.get(b), "name": tx(bands[b]["_nameTextID"]) if b in bands else None}
|
|
1077
|
+
for b in music["_bandIDs"]],
|
|
1078
|
+
"music": snd(music["_musicSoundID"]),
|
|
1079
|
+
"jingle": snd(music["_jingleSoundID"]),
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def extract_audio(cat, master_dir, music_id: int, out_dir, audio_fmt: str = "flac", *,
|
|
1084
|
+
flac_level: int = cri.FLAC_LEVEL, also=None, rows: dict | None = None) -> dict:
|
|
1085
|
+
"""<out>/audio/<cueSheet>/: the live BGM cue sheet decoded per cue + cues.json (cri.decode; `flac_level`,
|
|
1086
|
+
`also` as there). -> the `audio` entry of extract's summary (paths relative to out_dir)."""
|
|
1087
|
+
out_dir = Path(out_dir)
|
|
1088
|
+
rows = rows or music_rows(Path(master_dir), music_id)
|
|
1089
|
+
sheet = rows["music"]["MasterSoundCueSheet"]["_cueSheetName"]
|
|
1090
|
+
cue = rows["music"]["MasterSound"]["_cueName"]
|
|
1091
|
+
files = cri.decode(cat, sheet, out_dir / "audio" / sheet, fmt=audio_fmt, flac_level=flac_level, also=also)
|
|
1092
|
+
|
|
1093
|
+
def rel(p) -> str:
|
|
1094
|
+
return Path(p).relative_to(out_dir).as_posix()
|
|
1095
|
+
return {"cueSheet": sheet, "cue": cue, "file": rel(files[cue]) if cue in files else None,
|
|
1096
|
+
"source": cri.layout(cat, sheet), "cues": {k: rel(v) for k, v in files.items()}}
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def extract(cat, master_dir, music_id: int, difficulty: str | int, out_dir, audio: bool = True,
|
|
1100
|
+
audio_fmt: str = "flac", *, flac_level: int = cri.FLAC_LEVEL, also=None, mirror: bool = False) -> dict:
|
|
1101
|
+
"""Write <out>/score/<file>.json (shipped chart JSON, un-gzipped), <file>.notes.json (converted runtime
|
|
1102
|
+
notes), <out>/score/master.json (master rows) and <out>/audio/<cueSheet>/ (the live BGM cue sheet decoded
|
|
1103
|
+
per cue + cues.json, extract_audio). `mirror`: also <file>.mirror.notes.json, the notes converted with the
|
|
1104
|
+
MirrorChart option on (convert(mirror=True); summary `notesMirror`)."""
|
|
1105
|
+
out_dir = Path(out_dir)
|
|
1106
|
+
sdir = out_dir / "score"
|
|
1107
|
+
sdir.mkdir(parents=True, exist_ok=True)
|
|
1108
|
+
rows = music_rows(Path(master_dir), music_id)
|
|
1109
|
+
diff = DIFFICULTIES[difficulty] if isinstance(difficulty, int) else difficulty
|
|
1110
|
+
score_row = rows["MasterLiveMusicScore"][diff]
|
|
1111
|
+
fname = score_row["_musicScoreTextFileName"]
|
|
1112
|
+
raw = fetch_chart(cat, fname)
|
|
1113
|
+
txt = gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw
|
|
1114
|
+
base = fname.rsplit("/", 1)[-1]
|
|
1115
|
+
(sdir / f"{base}.json").write_bytes(txt)
|
|
1116
|
+
source = {"key": chart_key(fname), "musicId": music_id, "difficulty": diff,
|
|
1117
|
+
"masterLiveMusicScoreId": score_row["_id"], "fullComboCount": score_row["_fullComboCount"]}
|
|
1118
|
+
root = load_bytes(raw)
|
|
1119
|
+
conv = convert(root)
|
|
1120
|
+
conv["source"] = source
|
|
1121
|
+
write_json(sdir / f"{base}.notes.json", conv)
|
|
1122
|
+
if mirror:
|
|
1123
|
+
conv_m = convert(root, mirror=True)
|
|
1124
|
+
conv_m["source"] = source
|
|
1125
|
+
write_json(sdir / f"{base}.mirror.notes.json", conv_m)
|
|
1126
|
+
write_json(sdir / "master.json", rows)
|
|
1127
|
+
def rel(p) -> str: # paths in summary.json are relative to out_dir, POSIX separators (loadable as relative URLs)
|
|
1128
|
+
return Path(p).relative_to(out_dir).as_posix()
|
|
1129
|
+
|
|
1130
|
+
summary = {"chart": rel(sdir / f"{base}.json"), "notes": rel(sdir / f"{base}.notes.json"),
|
|
1131
|
+
**({"notesMirror": rel(sdir / f"{base}.mirror.notes.json")} if mirror else {}),
|
|
1132
|
+
"master": rel(sdir / "master.json"), "judgementNoteCount": conv["judgementNoteCount"],
|
|
1133
|
+
"fullComboCount": score_row["_fullComboCount"], "noteCount": len(conv["notes"]),
|
|
1134
|
+
"lastNoteTimeMs": conv["lastNoteTimeMs"]}
|
|
1135
|
+
if audio:
|
|
1136
|
+
summary["audio"] = extract_audio(cat, master_dir, music_id, out_dir, audio_fmt, flac_level=flac_level,
|
|
1137
|
+
also=also, rows=rows)
|
|
1138
|
+
write_json(out_dir / "score" / "summary.json", summary)
|
|
1139
|
+
return summary
|