613 lines
20 KiB
Python
613 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from dataclasses import field
|
|
|
|
import xmltodict
|
|
|
|
MIN_POS = 0
|
|
MAX_POS = 65536
|
|
|
|
DRS_LEFT = 1
|
|
DRS_RIGHT = 2
|
|
DRS_DOWN = 3
|
|
DRS_JUMP = 4
|
|
|
|
TPS_MULTIPLIER = 0.08 # Multiply this by BPM to get ticks per second
|
|
|
|
DRS_ALBUM_ID = 2022121400
|
|
ALBUM_NAME = f'DANCERUSH STARDOM ({DRS_ALBUM_ID})'
|
|
DEFAULT_TRACK_DIR = f'tracks/{ALBUM_NAME}/'
|
|
OUTPUT_ZIP_NAME = 'DANCERUSH_STARDOM.zip'
|
|
|
|
|
|
@dataclass
|
|
class DRSSongInfo:
|
|
artist_name: str
|
|
artist_yomigana: str
|
|
genre: int
|
|
title_name: str
|
|
title_yomigana: str
|
|
bpm_max: int
|
|
bpm_min: int
|
|
distribution_date: int
|
|
license: str
|
|
region: str
|
|
volume: int
|
|
|
|
|
|
@dataclass
|
|
class DRSSongDifficulty:
|
|
track: DRSTrack
|
|
difnum: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class DRSSongDifficulties:
|
|
difficulty_1a: DRSSongDifficulty | None = None
|
|
difficulty_1b: DRSSongDifficulty | None = None
|
|
difficulty_2a: DRSSongDifficulty | None = None
|
|
difficulty_2b: DRSSongDifficulty | None = None
|
|
|
|
@property
|
|
def with_attrs_as_str(self) -> dict[str, DRSSongDifficulty]:
|
|
return {
|
|
'difficulty_1a': self.difficulty_1a,
|
|
'difficulty_1b': self.difficulty_1b,
|
|
'difficulty_2a': self.difficulty_2a,
|
|
'difficulty_2b': self.difficulty_2b,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class DRSSongData:
|
|
song_id: int
|
|
difficulties: DRSSongDifficulties | None = None
|
|
info: DRSSongInfo | None = None
|
|
|
|
@property
|
|
def ogg(self) -> str:
|
|
return f'{str(self.song_id)}.ogg'
|
|
|
|
@property
|
|
def png(self) -> str:
|
|
return f'{str(self.song_id)}.png'
|
|
|
|
@classmethod
|
|
def from_xml_dict(cls, data: dict, difficulties: DRSSongDifficulties) -> DRSSongData:
|
|
return cls(
|
|
song_id=int(data['@id']),
|
|
difficulties=difficulties,
|
|
info=DRSSongInfo(
|
|
artist_name=data['info']['artist_name'].get('#text'),
|
|
artist_yomigana=data['info']['artist_yomigana'].get('#text'),
|
|
genre=int(data['info']['genre']['#text']),
|
|
title_name=data['info']['title_name']['#text'],
|
|
title_yomigana=data['info']['title_yomigana']['#text'],
|
|
bpm_max=int(data['info']['bpm_max']['#text']),
|
|
bpm_min=int(data['info']['bpm_min']['#text']),
|
|
distribution_date=int(
|
|
data['info']['distribution_date']['#text'],
|
|
),
|
|
license=data['info']['license'].get('#text'),
|
|
region=data['info']['region']['#text'],
|
|
volume=int(data['info']['volume']['#text']),
|
|
),
|
|
)
|
|
|
|
@classmethod
|
|
def from_json_dict(cls, data: dict):
|
|
difficulties = data['difficulties']
|
|
info = data['info']
|
|
return cls(
|
|
song_id=int(data['song_id']),
|
|
difficulties=DRSSongDifficulties(
|
|
difficulty_1a=DRSSongDifficulty(
|
|
track=DRSTrack.from_json_dict(
|
|
difficulties['difficulty_1a']['track'],
|
|
),
|
|
difnum=difficulties['difficulty_1a']['difnum'],
|
|
) if difficulties['difficulty_1a'] else None,
|
|
difficulty_1b=DRSSongDifficulty(
|
|
track=DRSTrack.from_json_dict(
|
|
difficulties['difficulty_1b']['track'],
|
|
),
|
|
difnum=difficulties['difficulty_1b']['difnum'],
|
|
) if difficulties['difficulty_1b'] else None,
|
|
difficulty_2a=DRSSongDifficulty(
|
|
track=DRSTrack.from_json_dict(
|
|
difficulties['difficulty_2a']['track'],
|
|
),
|
|
difnum=difficulties['difficulty_2a']['difnum'],
|
|
) if difficulties['difficulty_2a'] else None,
|
|
difficulty_2b=DRSSongDifficulty(
|
|
track=DRSTrack.from_json_dict(
|
|
difficulties['difficulty_2b']['track'],
|
|
),
|
|
difnum=difficulties['difficulty_2b']['difnum'],
|
|
) if difficulties['difficulty_2b'] else None,
|
|
),
|
|
info=DRSSongInfo(
|
|
artist_name=info['artist_name'],
|
|
artist_yomigana=info['artist_yomigana'],
|
|
genre=info['genre'],
|
|
title_name=info['title_name'],
|
|
title_yomigana=info['title_yomigana'],
|
|
bpm_max=info['bpm_max'],
|
|
bpm_min=info['bpm_min'],
|
|
distribution_date=info['distribution_date'],
|
|
license=info['license'],
|
|
region=info['region'],
|
|
volume=info['volume'],
|
|
),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DRSTrackBPMInfo:
|
|
bpm: int
|
|
tick: int | None = None
|
|
time: int | None = None
|
|
delta_time: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class DRSTrackMeasureInfo:
|
|
denomi: int
|
|
num: int
|
|
tick: int | None = None
|
|
time: int | None = None
|
|
delta_time: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class DRSTrackTimeInfo:
|
|
time_unit: int
|
|
|
|
|
|
@dataclass
|
|
class DRSTrackInfo:
|
|
end_tick: int
|
|
time_unit: DRSTrackTimeInfo
|
|
bpm_info: list[DRSTrackBPMInfo] = field(default_factory=list)
|
|
measure_info: list[DRSTrackMeasureInfo] = field(default_factory=list)
|
|
|
|
@property
|
|
def highest_bpm(self) -> int:
|
|
return max(bpm_info.bpm for bpm_info in self.bpm_info)
|
|
|
|
def determine_seconds_of_tick(self, target_tick: int) -> float:
|
|
total_seconds = 0.0
|
|
relevant_bpm_infos = sorted(
|
|
[b for b in self.bpm_info if b.tick <= target_tick],
|
|
key=lambda x: x.tick,
|
|
reverse=True,
|
|
)
|
|
|
|
for idx, current_bpm in enumerate(relevant_bpm_infos[:-1]):
|
|
next_bpm = relevant_bpm_infos[idx + 1]
|
|
tick_range = current_bpm.tick - next_bpm.tick
|
|
ticks_per_second = next_bpm.bpm * TPS_MULTIPLIER
|
|
seconds_for_range = tick_range / ticks_per_second
|
|
total_seconds += seconds_for_range
|
|
|
|
closest_bpm_info = relevant_bpm_infos[0]
|
|
remaining_ticks = target_tick - closest_bpm_info.tick
|
|
ticks_per_second = closest_bpm_info.bpm * TPS_MULTIPLIER
|
|
total_seconds += remaining_ticks / ticks_per_second
|
|
return total_seconds
|
|
|
|
@classmethod
|
|
def from_xml_dict(cls, data: dict):
|
|
if type(data['measure_info']['measure']) is list:
|
|
drs_track_measure_info = []
|
|
for measure in data['measure_info']['measure']:
|
|
has_tick = 'tick' in measure
|
|
has_time = 'time' in measure
|
|
has_delta_time = 'delta_time' in measure
|
|
measure_info = DRSTrackMeasureInfo(
|
|
int(measure['denomi']['#text']),
|
|
int(measure['num']['#text']),
|
|
int(measure['tick']['#text']) if has_tick else None,
|
|
int(measure['time']['#text']) if has_time else None,
|
|
int(
|
|
measure['delta_time']['#text'],
|
|
) if has_delta_time else None,
|
|
)
|
|
drs_track_measure_info.append(measure_info)
|
|
else:
|
|
has_tick = 'tick' in data['measure_info']['measure']
|
|
has_time = 'time' in data['measure_info']['measure']
|
|
has_delta_time = 'delta_time' in data['measure_info']['measure']
|
|
drs_track_measure_info = [
|
|
DRSTrackMeasureInfo(
|
|
int(data['measure_info']['measure']['denomi']['#text']),
|
|
int(data['measure_info']['measure']['num']['#text']),
|
|
int(
|
|
data['measure_info']['measure']['tick']
|
|
['#text'],
|
|
) if has_tick else None,
|
|
int(
|
|
data['measure_info']['measure']['time']
|
|
['#text'],
|
|
) if has_time else None,
|
|
int(
|
|
data['measure_info']['measure']['delta_time']
|
|
['#text'],
|
|
) if has_delta_time else None,
|
|
),
|
|
]
|
|
|
|
if type(data['bpm_info']['bpm']) is list:
|
|
drs_track_bpm_info = []
|
|
for bpm in data['bpm_info']['bpm']:
|
|
has_tick = 'tick' in bpm
|
|
has_time = 'time' in bpm
|
|
has_delta_time = 'delta_time' in bpm
|
|
bmp_info = DRSTrackBPMInfo(
|
|
int(bpm['bpm']['#text']),
|
|
int(bpm['tick']['#text']) if has_tick else None,
|
|
int(bpm['time']['#text']) if has_time else None,
|
|
int(
|
|
bpm['delta_time']['#text'],
|
|
) if has_delta_time else None,
|
|
)
|
|
drs_track_bpm_info.append(bmp_info)
|
|
else:
|
|
has_tick = 'tick' in data['bpm_info']['bpm']
|
|
has_time = 'time' in data['bpm_info']['bpm']
|
|
has_delta_time = 'delta_time' in data['bpm_info']['bpm']
|
|
drs_track_bpm_info = [
|
|
DRSTrackBPMInfo(
|
|
int(data['bpm_info']['bpm']['bpm']['#text']),
|
|
int(
|
|
data['bpm_info']['bpm']['tick']
|
|
['#text'],
|
|
) if has_tick else None,
|
|
int(
|
|
data['bpm_info']['bpm']['time']
|
|
['#text'],
|
|
) if has_time else None,
|
|
int(
|
|
data['bpm_info']['bpm']['delta_time']
|
|
['#text'],
|
|
) if has_delta_time else None,
|
|
),
|
|
]
|
|
|
|
if 'time_unit' in data:
|
|
time_unit = int(data['time_unit']['#text'])
|
|
elif 'tick' in data:
|
|
time_unit = int(data['tick']['#text'])
|
|
else:
|
|
time_unit = 480
|
|
|
|
return cls(
|
|
end_tick=int(
|
|
data['end_tick']['#text'],
|
|
) if 'end_tick' in data else None,
|
|
time_unit=DRSTrackTimeInfo(time_unit),
|
|
bpm_info=drs_track_bpm_info,
|
|
measure_info=drs_track_measure_info,
|
|
)
|
|
|
|
@classmethod
|
|
def from_json_dict(cls, data: dict):
|
|
|
|
return cls(
|
|
end_tick=int(data['end_tick']) if data.get(
|
|
'end_tick',
|
|
) is not None else None,
|
|
time_unit=DRSTrackTimeInfo(int(data['time_unit']['time_unit'])),
|
|
bpm_info=[
|
|
DRSTrackBPMInfo(
|
|
bpm=int(bpm['bpm']) if bpm.get(
|
|
'bpm',
|
|
) is not None else None,
|
|
tick=int(bpm['tick']) if bpm.get(
|
|
'tick',
|
|
) is not None else None,
|
|
) for bpm in data['bpm_info']
|
|
],
|
|
measure_info=[
|
|
DRSTrackMeasureInfo(
|
|
denomi=int(measure['denomi']),
|
|
num=int(measure['num']),
|
|
tick=int(measure['tick']) if measure.get(
|
|
'tick',
|
|
) is not None else None,
|
|
) for measure in data['measure_info']
|
|
],
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DRSTrackStepTickInfo:
|
|
start_tick: int | None = None
|
|
end_tick: int | None = None
|
|
stime_ms: int | None = None
|
|
etime_ms: int | None = None
|
|
stime_dt: int | None = None
|
|
etime_dt: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class DRSTrackStepPositionInfo:
|
|
left_pos: int
|
|
right_pos: int
|
|
|
|
@property
|
|
def to_dance_dash_x(self):
|
|
center_pos = (self.left_pos + self.right_pos) / 2
|
|
mapped_value = 1 + (center_pos / MAX_POS) * 8
|
|
return round(mapped_value)
|
|
|
|
|
|
@dataclass
|
|
class DRSTrackPoint(DRSTrackStepPositionInfo):
|
|
tick: int
|
|
left_end_pos: int | None = None
|
|
right_end_pos: int | None = None
|
|
point_time: int | None = None
|
|
|
|
@property
|
|
def to_dance_dash_end_x(self):
|
|
center_pos = (self.left_end_pos + self.right_end_pos) / 2
|
|
mapped_value = 1 + (center_pos / MAX_POS) * 8
|
|
return round(mapped_value)
|
|
|
|
@property
|
|
def tail(self):
|
|
end_x = self.to_dance_dash_end_x
|
|
if end_x in range(2, 9):
|
|
return end_x + (end_x - self.to_dance_dash_x)
|
|
return end_x
|
|
|
|
|
|
@dataclass
|
|
class DRSTrackStepPlayerInfo:
|
|
player_id: int
|
|
|
|
|
|
@dataclass
|
|
class DRSTrackStep:
|
|
tick_info: DRSTrackStepTickInfo
|
|
kind: DRS_LEFT | DRS_RIGHT | DRS_DOWN | DRS_JUMP
|
|
position_info: DRSTrackStepPositionInfo
|
|
player_info: DRSTrackStepPlayerInfo
|
|
long_point: list[DRSTrackPoint] = field(default_factory=list)
|
|
|
|
@property
|
|
def is_down_or_up(self):
|
|
return self.kind in (DRS_DOWN, DRS_JUMP)
|
|
|
|
@classmethod
|
|
def from_xml_dict(cls, data: dict):
|
|
|
|
points = []
|
|
if long_point := data.get('long_point'):
|
|
if points := long_point.get('point'):
|
|
if not isinstance(points, list):
|
|
points = [points]
|
|
|
|
has_start_tick = 'start_tick' in data
|
|
has_end_tick = 'end_tick' in data
|
|
|
|
has_stime_ms = 'stime_ms' in data
|
|
has_etime_ms = 'etime_ms' in data
|
|
has_stime_dt = 'stime_dt' in data
|
|
has_etime_dt = 'etime_dt' in data
|
|
|
|
main_left_pos = int(data['left_pos']['#text']) if 'left_pos' in data else int(
|
|
data['pos_left']['#text'],
|
|
)
|
|
main_right_pos = int(data['right_pos']['#text']) if 'right_pos' in data else int(
|
|
data['pos_right']['#text'],
|
|
)
|
|
|
|
track_points = []
|
|
for point in points:
|
|
if 'left_pos' in point:
|
|
left_pos = safe_int(point['left_pos']['#text'])
|
|
elif 'pos_left' in point:
|
|
left_pos = safe_int(point['pos_left']['#text'])
|
|
|
|
if 'right_pos' in point:
|
|
right_pos = safe_int(point['right_pos']['#text'])
|
|
elif 'pos_right' in point:
|
|
right_pos = safe_int(point['pos_right']['#text'])
|
|
|
|
left_end_pos = None
|
|
if 'left_end_pos' in point:
|
|
left_end_pos = safe_int(point['left_end_pos']['#text'])
|
|
elif 'pos_lend' in point:
|
|
left_end_pos = safe_int(point['pos_lend']['#text'])
|
|
|
|
right_end_pos = None
|
|
if 'right_end_pos' in point:
|
|
right_end_pos = safe_int(point['right_end_pos']['#text'])
|
|
elif 'pos_rend' in point:
|
|
right_end_pos = safe_int(point['pos_rend']['#text'])
|
|
|
|
drs_track_point = DRSTrackPoint(
|
|
tick=safe_int(point.get('tick', {}).get('#text')),
|
|
left_pos=left_pos,
|
|
right_pos=right_pos,
|
|
left_end_pos=left_end_pos,
|
|
right_end_pos=right_end_pos,
|
|
point_time=safe_int(
|
|
point.get('point_time', {}).get('#text'),
|
|
),
|
|
)
|
|
track_points.append(drs_track_point)
|
|
|
|
return cls(
|
|
DRSTrackStepTickInfo(
|
|
start_tick=int(
|
|
data['start_tick']['#text'],
|
|
) if has_start_tick else None,
|
|
end_tick=int(
|
|
data['end_tick']['#text'],
|
|
) if has_end_tick else None,
|
|
stime_ms=int(
|
|
data['stime_ms']['#text'],
|
|
) if has_stime_ms else None,
|
|
etime_ms=int(
|
|
data['etime_ms']['#text'],
|
|
) if has_etime_ms else None,
|
|
stime_dt=int(
|
|
data['stime_dt']['#text'],
|
|
) if has_stime_dt else None,
|
|
etime_dt=int(
|
|
data['etime_dt']['#text'],
|
|
) if has_etime_dt else None,
|
|
),
|
|
int(data['kind']['#text']),
|
|
DRSTrackStepPositionInfo(
|
|
left_pos=main_left_pos,
|
|
right_pos=main_right_pos,
|
|
),
|
|
DRSTrackStepPlayerInfo(int(data['player_id']['#text'])),
|
|
track_points,
|
|
)
|
|
|
|
@classmethod
|
|
def from_json_dict(cls, data: dict):
|
|
|
|
track_points = []
|
|
for point in data['long_point']:
|
|
if 'left_pos' in point:
|
|
left_pos = safe_int(point['left_pos'])
|
|
elif 'pos_left' in point:
|
|
left_pos = safe_int(point['pos_left'])
|
|
if 'right_pos' in point:
|
|
right_pos = safe_int(point['right_pos'])
|
|
elif 'pos_right' in point:
|
|
right_pos = safe_int(point['pos_right'])
|
|
|
|
track_point = DRSTrackPoint(
|
|
tick=safe_int(point['tick']),
|
|
left_pos=left_pos,
|
|
right_pos=right_pos,
|
|
left_end_pos=int(point['left_end_pos']) if point.get(
|
|
'left_end_pos',
|
|
) is not None else None,
|
|
right_end_pos=int(point['right_end_pos']) if point.get(
|
|
'right_end_pos',
|
|
) is not None else None,
|
|
point_time=safe_int(point['point_time']),
|
|
)
|
|
track_points.append(track_point)
|
|
|
|
return cls(
|
|
DRSTrackStepTickInfo(
|
|
start_tick=int(data['tick_info']['start_tick']) if data['tick_info'].get(
|
|
'start_tick',
|
|
) is not None else None,
|
|
end_tick=int(data['tick_info']['end_tick']) if data['tick_info'].get(
|
|
'end_tick',
|
|
) is not None else None,
|
|
stime_ms=int(data['tick_info']['stime_ms']) if data['tick_info'].get(
|
|
'stime_ms',
|
|
) is not None else None,
|
|
etime_ms=int(data['tick_info']['etime_ms']) if data['tick_info'].get(
|
|
'etime_ms',
|
|
) is not None else None,
|
|
stime_dt=int(data['tick_info']['stime_dt']) if data['tick_info'].get(
|
|
'stime_dt',
|
|
) is not None else None,
|
|
etime_dt=int(data['tick_info']['etime_dt']) if data['tick_info'].get(
|
|
'etime_dt',
|
|
) is not None else None,
|
|
),
|
|
int(data['kind']),
|
|
DRSTrackStepPositionInfo(
|
|
int(data['position_info']['left_pos']),
|
|
int(data['position_info']['right_pos']),
|
|
),
|
|
DRSTrackStepPlayerInfo(int(data['player_info']['player_id'])),
|
|
track_points,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DRSClip:
|
|
start_time: int
|
|
end_time: int
|
|
|
|
@classmethod
|
|
def from_xml_dict(cls, data: dict):
|
|
start_time = None
|
|
if 'start_time' in data:
|
|
start_time = int(data['start_time']['#text'])
|
|
elif 'stime_ms' in data:
|
|
start_time = int(data['stime_ms']['#text'])
|
|
|
|
end_time = None
|
|
if 'end_time' in data:
|
|
end_time = int(data['end_time']['#text'])
|
|
elif 'etime_ms' in data:
|
|
end_time = int(data['etime_ms']['#text'])
|
|
|
|
return cls(start_time, end_time)
|
|
|
|
@classmethod
|
|
def from_json_dict(cls, data: dict):
|
|
return cls(
|
|
int(data['start_time']),
|
|
int(data['end_time']),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DRSTrack:
|
|
seq_version: int
|
|
info: DRSTrackInfo
|
|
sequence_data: list[DRSTrackStep] = field(default_factory=list)
|
|
clip: DRSClip = None
|
|
|
|
@classmethod
|
|
def from_xml_dict(cls, data: dict):
|
|
data = data['data']
|
|
|
|
# not really representing the data properly, but we need this for timings on ver 9 songs (i think)
|
|
clip = None
|
|
if 'rec_data' in data and data['rec_data'] and type(data['rec_data']['clip']) is not list:
|
|
clip = DRSClip.from_xml_dict(data['rec_data']['clip'])
|
|
|
|
return cls(
|
|
int(data['seq_version']['#text']),
|
|
DRSTrackInfo.from_xml_dict(data['info']),
|
|
[
|
|
DRSTrackStep.from_xml_dict(step)
|
|
for step in data['sequence_data']['step']
|
|
],
|
|
clip,
|
|
)
|
|
|
|
@classmethod
|
|
def from_xml(cls, path: str):
|
|
data = open(path, encoding='utf-8').read()
|
|
return cls.from_xml_dict(xmltodict.parse(data))
|
|
|
|
@classmethod
|
|
def from_json_dict(cls, data: dict):
|
|
return cls(
|
|
int(data['seq_version']),
|
|
DRSTrackInfo.from_json_dict(data['info']),
|
|
[
|
|
DRSTrackStep.from_json_dict(step)
|
|
for step in data['sequence_data']
|
|
],
|
|
clip=DRSClip.from_json_dict(data['clip']) if data.get(
|
|
'clip',
|
|
) is not None else None,
|
|
)
|
|
|
|
|
|
def safe_int(value):
|
|
try:
|
|
return int(value)
|
|
except (ValueError, TypeError):
|
|
return None
|