from datetime import timedelta
from enum import unique, IntEnum
from functools import partial
import inspect
from itertools import chain, islice, cycle
import operator as op
import re
from zipfile import ZipFile
import numpy as np
from .game_mode import GameMode
from .mod import ar_to_ms, ms_to_ar, circle_radius, od_to_ms_300, ms_300_to_od
from .position import Position, Point
from .utils import (
accuracy as calculate_accuracy,
lazyval,
memoize,
no_default,
orange,
)
from .curve import Curve
def _get(cs, ix, default=no_default):
try:
return cs[ix]
except IndexError:
if default is no_default:
raise
return default
class TimingPoint:
"""A timing point assigns properties to an offset into a beatmap.
Parameters
----------
offset : timedelta
When this ``TimingPoint`` takes effect.
ms_per_beat : float
The milliseconds per beat, this is another representation of BPM.
meter : int
The number of beats per measure.
sample_type : int
The type of hit sound samples that are used.
sample_set : int
The set of hit sound samples that are used.
volume : int
The volume of hit sounds in the range [0, 100]. This value will be
clipped if outside the range.
parent : TimingPoint or None
The parent of an inherited timing point. An inherited timing point
differs from a normal timing point in that the ``ms_per_beat`` value is
negative, and defines a new ``ms_per_beat`` based on the parent
timing point. This can be used to change volume without affecting
offset timing, or changing slider speeds. If this is not an inherited
timing point the parent should be ``None``.
kiai_mode : bool
Wheter or not kiai time effects are active.
"""
def __init__(self,
offset,
ms_per_beat,
meter,
sample_type,
sample_set,
volume,
parent,
kiai_mode):
self.offset = offset
self.ms_per_beat = ms_per_beat
self.meter = meter
self.sample_type = sample_type
self.sample_set = sample_set
self.volume = np.clip(volume, 0, 100)
self.parent = parent
self.kiai_mode = kiai_mode
@lazyval
def half_time(self):
"""The ``TimingPoint`` as it would appear with
:data:`~slider.mod.Mod.half_time` enabled.
"""
return type(self)(
4 * self.offset / 3,
self.ms_per_beat if self.inherited else (4 * self.ms_per_beat / 3),
self.meter,
self.sample_type,
self.sample_set,
self.volume,
getattr(self.parent, 'half_time', None),
self.kiai_mode,
)
def double_time(self):
"""The ``TimingPoint`` as it would appear with
:data:`~slider.mod.Mod.double_time` enabled.
"""
return type(self)(
2 * self.offset / 3,
self.ms_per_beat if self.inherited else (2 * self.ms_per_beat / 3),
self.meter,
self.sample_type,
self.sample_set,
self.volume,
getattr(self.parent, 'double_time', None),
self.kiai_mode,
)
@lazyval
def bpm(self):
"""The bpm of this timing point.
If this is an inherited timing point this value will be None.
"""
ms_per_beat = self.ms_per_beat
if ms_per_beat < 0:
return None
return round(60000 / ms_per_beat)
def __repr__(self):
if self.parent is None:
inherited = 'inherited '
else:
inherited = ''
return (
f'<{type(self).__qualname__}:'
f' {inherited}{self.offset.total_seconds() * 1000:g}ms>'
)
@classmethod
def parse(cls, data, parent):
"""Parse a TimingPoint object from a line in a ``.osu`` file.
Parameters
----------
data : str
The line to parse.
parent : TimingPoint
The last non-inherited timing point.
Returns
-------
timing_point : TimingPoint
The parsed timing point.
Raises
------
ValueError
Raised when ``data`` does not describe a ``TimingPoint`` object.
"""
try:
offset, ms_per_beat, *rest = data.split(',')
except ValueError:
raise ValueError(
f'failed to parse {cls.__qualname__} from {data!r}',
)
try:
offset_float = float(offset)
except ValueError:
raise ValueError(f'offset should be a float, got {offset!r}')
offset = timedelta(milliseconds=offset_float)
try:
ms_per_beat = float(ms_per_beat)
except ValueError:
raise ValueError(
f'ms_per_beat should be a float, got {ms_per_beat!r}',
)
try:
meter = int(_get(rest, 0, '4'))
except ValueError:
raise ValueError(f'meter should be an int, got {meter!r}')
try:
sample_type = int(_get(rest, 1, '0'))
except ValueError:
raise ValueError(
f'sample_type should be an int, got {sample_type!r}',
)
try:
sample_set = int(_get(rest, 2, '0'))
except ValueError:
raise ValueError(
f'sample_set should be an int, got {sample_set!r}',
)
try:
volume = int(_get(rest, 3, '1'))
except ValueError:
raise ValueError(f'volume should be an int, got {volume!r}')
try:
inherited = not bool(int(_get(rest, 4, '1')))
except ValueError:
raise ValueError(f'inherited should be a bool, got {inherited!r}')
try:
kiai_mode = bool(int(_get(rest, 5, '0')))
except ValueError:
raise ValueError(f'kiai_mode should be a bool, got {kiai_mode!r}')
return cls(
offset=offset,
ms_per_beat=ms_per_beat,
meter=meter,
sample_type=sample_type,
sample_set=sample_set,
volume=volume,
parent=parent if inherited else None,
kiai_mode=kiai_mode,
)
class HitObjectMeta(type):
def __new__(cls, name, bases, dict_):
self = super().__new__(cls, name, bases, dict_)
self._parameter_names = tuple(inspect.signature(self).parameters)
return self
[docs]class HitObject(metaclass=HitObjectMeta):
"""An abstract hit element for osu! standard.
Parameters
----------
position : Position
Where this element appears on the screen.
time : timedelta
When this element appears in the map.
hitsound : int
The hitsound to play when this object is hit.
addition : str, optional
Unknown currently.
"""
time_related_attributes = frozenset({'time'})
def __init__(self, position, time, hitsound, addition='0:0:0:0'):
self.position = position
self.time = time
self.hitsound = hitsound
self.addition = addition
def __repr__(self):
return (
f'<{type(self).__qualname__}: {self.position},'
f' {self.time.total_seconds() * 1000:g}ms>'
)
@property
def _as_dict(self):
return {k: getattr(self, k) for k in self._parameter_names}
def _time_modify(self, coefficient):
"""Modify this ``HitObject`` by multiplying time related attributes
by the ``coefficient``.
Parameters
----------
coefficient : float
The coefficient to multiply the ``time_related`` values by.
Returns
-------
modified : HitObject
The modified hit object.
"""
time_related_attributes = self.time_related_attributes
kwargs = {}
for name, value in self._as_dict.items():
if name in time_related_attributes:
value *= coefficient
kwargs[name] = value
return type(self)(**kwargs)
@lazyval
def half_time(self):
"""The ``HitObject`` as it would appear with
:data:`~slider.mod.Mod.half_time` enabled.
"""
return self._time_modify(4 / 3)
@lazyval
def double_time(self):
"""The ``HitObject`` as it would appear with
:data:`~slider.mod.Mod.double_time` enabled.
"""
return self._time_modify(2 / 3)
@lazyval
def hard_rock(self):
"""The ``HitObject`` as it would appear with
:data:`~slider.mod.Mod.hard_rock` enabled.
"""
kwargs = {}
for name in inspect.signature(type(self)).parameters:
value = getattr(self, name)
if name == 'position':
value = Position(value.x, 384 - value.y)
kwargs[name] = value
return type(self)(**kwargs)
@classmethod
[docs] def parse(cls, data, timing_points, slider_multiplier, slider_tick_rate):
"""Parse a HitObject object from a line in a ``.osu`` file.
Parameters
----------
data : str
The line to parse.
timing_points : list[TimingPoint]
The timing points in the map.
slider_multiplier : float
The slider multiplier for computing slider end_time and ticks.
slider_tick_rate : float
The slider tick rate for computing slider end_time and ticks.
Returns
-------
hit_objects : HitObject
The parsed hit object. This will be the concrete subclass given
the type.
Raises
------
ValueError
Raised when ``data`` does not describe a ``HitObject`` object.
"""
try:
x, y, time, type_, hitsound, *rest = data.split(',')
except ValueError:
raise ValueError(f'not enough elements in line, got {data!r}')
try:
x = int(x)
except ValueError:
raise ValueError(f'x should be an int, got {x!r}')
try:
y = int(y)
except ValueError:
raise ValueError(f'y should be an int, got {y!r}')
try:
time = timedelta(milliseconds=int(time))
except ValueError:
raise ValueError(f'type should be an int, got {time!r}')
try:
type_ = int(type_)
except ValueError:
raise ValueError(f'type should be an int, got {type_!r}')
try:
hitsound = int(hitsound)
except ValueError:
raise ValueError(f'hitsound should be an int, got {hitsound!r}')
if type_ & Circle.type_code:
parse = Circle._parse
elif type_ & Slider.type_code:
parse = partial(
Slider._parse,
timing_points=timing_points,
slider_multiplier=slider_multiplier,
slider_tick_rate=slider_tick_rate,
)
elif type_ & Spinner.type_code:
parse = Spinner._parse
elif type_ & HoldNote.type_code:
parse = HoldNote._parse
else:
raise ValueError(f'unknown type code {type_!r}')
return parse(Position(x, y), time, hitsound, rest)
[docs]class Circle(HitObject):
"""A circle hit element.
Parameters
----------
position : Position
Where this circle appears on the screen.
time : timedelta
When this circle appears in the map.
"""
type_code = 1
@classmethod
def _parse(cls, position, time, hitsound, rest):
if len(rest) > 1:
raise ValueError('extra data: {rest!r}')
return cls(position, time, hitsound, *rest)
[docs]class Spinner(HitObject):
"""A spinner hit element
Parameters
----------
position : Position
Where this spinner appears on the screen.
time : timedelta
When this spinner appears in the map.
end_time : timedelta
When this spinner ends in the map.
addition : str
Hitsound additions.
"""
type_code = 8
time_related_attributes = frozenset({'time', 'end_time'})
def __init__(self,
position,
time,
hitsound,
end_time,
addition='0:0:0:0:'):
super().__init__(position, time, hitsound, addition)
self.end_time = end_time
@classmethod
def _parse(cls, position, time, hitsound, rest):
try:
end_time, *rest = rest
except ValueError:
raise ValueError('missing end_time')
try:
end_time = int(end_time)
except ValueError:
raise ValueError(f'end_time should be an int, got {end_time!r}')
if len(rest) > 1:
raise ValueError(f'extra data: {rest!r}')
return cls(position, time, hitsound, end_time, *rest)
[docs]class Slider(HitObject):
"""A slider hit element.
Parameters
----------
position : Position
Where this slider appears on the screen.
time : datetime.timedelta
When this slider appears in the map.
end_time : datetime.timedelta
When this slider ends in the map
hitsound : int
The sound played on the ticks of the slider.
curve : Curve
The slider's curve function.
length : int
The length of this slider in osu! pixels.
ticks : int
The number of slider ticks including the head and tail of the slider.
num_beats : int
The number of beats that this slider spans.
tick_rate : float
The rate at which ticks appear along sliders.
ms_per_beat : int
The milliseconds per beat during the segment of the beatmap that this
slider appears in.
edge_sounds : list[int]
A list of hitsounds for each edge.
edge_additions : list[str]
A list of additions for each edge.
addition : str
Hitsound additions.
"""
type_code = 2
time_related_attributes = frozenset({'time', 'end_time', 'ms_per_beat'})
def __init__(self,
position,
time,
end_time,
hitsound,
curve,
repeat,
length,
ticks,
num_beats,
tick_rate,
ms_per_beat,
edge_sounds,
edge_additions,
addition='0:0:0:0'):
super().__init__(position, time, hitsound, addition)
self.end_time = end_time
self.curve = curve
self.repeat = repeat
self.length = length
self.ticks = ticks
self.num_beats = num_beats
self.tick_rate = tick_rate
self.ms_per_beat = ms_per_beat
self.edge_sounds = edge_sounds
self.edge_additions = edge_additions
@lazyval
def tick_points(self):
"""The position and time of each slider tick.
"""
repeat = self.repeat
time = self.time
repeat_duration = (self.end_time - time) / repeat
curve = self.curve
pre_repeat_ticks = []
append_tick = pre_repeat_ticks.append
beats_per_repeat = self.num_beats / repeat
for t in orange(self.tick_rate, beats_per_repeat, self.tick_rate):
pos = curve(t / beats_per_repeat)
timediff = timedelta(milliseconds=t * self.ms_per_beat)
append_tick(Point(pos.x, pos.y, time + timediff))
pos = curve(1)
timediff = repeat_duration
append_tick(Point(pos.x, pos.y, time + timediff))
repeat_ticks = [
Point(p.x, p.y, pre_repeat_tick.offset)
for pre_repeat_tick, p in zip(
pre_repeat_ticks,
chain(pre_repeat_ticks[-2::-1], [self.position])
)
]
tick_sequences = islice(
cycle([pre_repeat_ticks, repeat_ticks]),
repeat,
)
return list(
chain.from_iterable(
(
Point(p.x, p.y, p.offset + n * repeat_duration)
for p in tick_sequence
)
for n, tick_sequence in enumerate(tick_sequences)
),
)
@lazyval
def hard_rock(self):
"""The ``HitObject`` as it would appear with
:data:`~slider.mod.Mod.hard_rock` enabled.
"""
kwargs = {}
for name in inspect.signature(type(self)).parameters:
value = getattr(self, name)
if name == 'position':
value = Position(value.x, 384 - value.y)
elif name == 'curve':
value = value.hard_rock
kwargs[name] = value
return type(self)(**kwargs)
@classmethod
def _parse(cls,
position,
time,
hitsound,
rest,
timing_points,
slider_multiplier,
slider_tick_rate):
try:
group_1, *rest = rest
except ValueError:
raise ValueError(f'missing required slider data in {rest!r}')
try:
slider_type, *raw_points = group_1.split('|')
except ValueError:
raise ValueError(
'expected slider type and points in the first'
f' element of rest, {rest!r}',
)
points = [position]
for point in raw_points:
try:
x, y = point.split(':')
except ValueError:
raise ValueError(
f'expected points in the form x:y, got {point!r}',
)
try:
x = int(x)
except ValueError:
raise ValueError('x should be an int, got {x!r}')
try:
y = int(y)
except ValueError:
raise ValueError('y should be an int, got {y!r}')
points.append(Position(x, y))
try:
repeat, *rest = rest
except ValueError:
raise ValueError(f'missing repeat in {rest!r}')
try:
repeat = int(repeat)
except ValueError:
raise ValueError(f'repeat should be an int, got {repeat!r}')
try:
pixel_length, *rest = rest
except ValueError:
raise ValueError(f'missing pixel_length in {rest!r}')
try:
pixel_length = float(pixel_length)
except ValueError:
raise ValueError(
f'pixel_length should be a float, got {pixel_length!r}',
)
try:
raw_edge_sounds_grouped, *rest = rest
except ValueError:
raw_edge_sounds_grouped = ''
raw_edge_sounds = raw_edge_sounds_grouped.split('|')
edge_sounds = []
if raw_edge_sounds != ['']:
for edge_sound in raw_edge_sounds:
try:
edge_sound = int(edge_sound)
except ValueError:
raise ValueError(
f'edge_sound should be an int, got {edge_sound!r}',
)
edge_sounds.append(edge_sound)
try:
edge_additions_grouped, *rest = rest
except ValueError:
edge_additions_grouped = ''
if edge_additions_grouped:
edge_additions = edge_additions_grouped.split('|')
else:
edge_additions = []
if len(rest) > 1:
raise ValueError(f'extra data: {rest!r}')
for tp in reversed(timing_points):
if tp.offset <= time:
break
else:
tp = timing_points[0]
if tp.parent is not None:
velocity_multiplier = -100 / tp.ms_per_beat
ms_per_beat = tp.parent.ms_per_beat
else:
velocity_multiplier = 1
ms_per_beat = tp.ms_per_beat
pixels_per_beat = slider_multiplier * 100 * velocity_multiplier
num_beats = (
np.round(((pixel_length * repeat) / pixels_per_beat) * 16) / 16
)
duration = timedelta(milliseconds=np.ceil(num_beats * ms_per_beat))
ticks = int(
(
(np.ceil((num_beats - 0.1) / repeat * slider_tick_rate) - 1)
) *
repeat +
repeat +
1
)
return cls(
position,
time,
time + duration,
hitsound,
Curve.from_kind_and_points(slider_type, points, pixel_length),
repeat,
pixel_length,
ticks,
num_beats,
slider_tick_rate,
ms_per_beat,
edge_sounds,
edge_additions,
*rest,
)
[docs]class HoldNote(HitObject):
"""A HoldNote hit element.
Parameters
----------
position : Position
Where this HoldNote appears on the screen.
time : timedelta
When this HoldNote appears in the map.
Notes
-----
A ``HoldNote`` can only appear in an osu!mania map.
"""
type_code = 128
@classmethod
def _parse(cls, position, time, hitsound, rest):
if len(rest) > 1:
raise ValueError('extra data: {rest!r}')
return cls(position, time, hitsound, *rest)
def _get_as_str(groups, section, field, default=no_default):
"""Lookup a field from a given section.
Parameters
----------
groups : dict[str, dict[str, str]]
The grouped osu! file.
section : str
The section to read from.
field : str
The field to read.
default : int, optional
A value to return if ``field`` is not in ``groups[section]``.
Returns
-------
cs : str
``groups[section][field]`` or default if ``field` is not in
``groups[section]``.
"""
try:
mapping = groups[section]
except KeyError:
if default is no_default:
raise ValueError(f'missing section {section!r}')
return default
try:
return mapping[field]
except KeyError:
if default is no_default:
raise ValueError(f'missing field {field!r} in section {section!r}')
return default
def _get_as_int(groups, section, field, default=no_default):
"""Lookup a field from a given section and parse it as an integer.
Parameters
----------
groups : dict[str, dict[str, str]]
The grouped osu! file.
section : str
The section to read from.
field : str
The field to read and parse.
default : int, optional
A value to return if ``field`` is not in ``groups[section]``.
Returns
-------
integer : int
``int(groups[section][field])`` or default if ``field` is not in
``groups[section]``.
"""
v = _get_as_str(groups, section, field, default)
if v is default:
return v
try:
return int(v)
except ValueError:
raise ValueError(
f'field {field!r} in section {section!r} should be an int,'
f' got {v!r}',
)
def _get_as_int_list(groups, section, field, default=no_default):
"""Lookup a field from a given section and parse it as an integer list.
Parameters
----------
groups : dict[str, dict[str, str]]
The grouped osu! file.
section : str
The section to read from.
field : str
The field to read and parse.
default : int, optional
A value to return if ``field`` is not in ``groups[section]``.
Returns
-------
ints : list[int]
``int(groups[section][field])`` or default if ``field` is not in
``groups[section]``.
"""
v = _get_as_str(groups, section, field, default)
if v is default:
return v
try:
return [int(e.strip()) for e in v.split(',')]
except ValueError:
raise ValueError(
f'field {field!r} in section {section!r} should be an int list,'
f' got {v!r}',
)
def _get_as_float(groups, section, field, default=no_default):
"""Lookup a field from a given section and parse it as an float
Parameters
----------
groups : dict[str, dict[str, str]]
The grouped osu! file.
section : str
The section to read from.
field : str
The field to read and parse.
default : float, optional
A value to return if ``field`` is not in ``groups[section]``.
Returns
-------
f : float
``float(groups[section][field])`` or default if ``field` is not in
``groups[section]``.
"""
v = _get_as_str(groups, section, field, default)
if v is default:
return v
try:
return float(v)
except ValueError:
raise ValueError(
f'field {field!r} in section {section!r} should be a float,'
f' got {v!r}',
)
def _get_as_bool(groups, section, field, default=no_default):
"""Lookup a field from a given section and parse it as an float
Parameters
----------
groups : dict[str, dict[str, str]]
The grouped osu! file.
section : str
The section to read from.
field : str
The field to read and parse.
default : float, optional
A value to return if ``field`` is not in ``groups[section]``.
Returns
-------
f : float
``float(groups[section][field])`` or default if ``field` is not in
``groups[section]``.
"""
v = _get_as_str(groups, section, field, default)
if v is default:
return v
try:
# cast to int then to bool because '0' is still True; bools are written
# to the file as '0' and '1' so this is safe.
return bool(int(v))
except ValueError:
raise ValueError(
f'field {field!r} in section {section!r} should be a bool,'
f' got {v!r}',
)
def _moving_average_by_time(times, data, delta, num):
"""Take the moving average of some values and sample it at regular
frequencies.
Parameters
----------
times : np.ndarray
The array of times to use in the average.
data : np.ndarray
The array of values to take the average of. Each column is averaged
independently.
delta : int or float
The length of the leading and trailing window in seconds
num : int
The number of samples to take.
Returns
-------
times : np.ndarray
A column vector of the times sampled at.
averages : np.ndarray
A column array of the averages. 1 column per column in the input
"""
# take an even sample from 0 to the end time
out_times = np.linspace(
times[0].item(),
times[-1].item(),
num,
dtype='timedelta64[ns]',
)
delta = np.timedelta64(int(delta * 1e9), 'ns')
# compute the start and stop indices for each sampled window
window_start_ixs = np.searchsorted(times[:, 0], out_times - delta)
window_stop_ixs = np.searchsorted(times[:, 0], out_times + delta)
# a 2d array of shape ``(num, 2)`` where each row holds the start and stop
# index for the window
window_ixs = np.stack([window_start_ixs, window_stop_ixs], axis=1)
# append a nan to the end of the values so that we can do many slices all
# the way to the end in reduceat
values = np.vstack([data, [np.nan] * data.shape[1]])
# sum the values in the ranges ``[window_start_ixs, window_stop_ixs)``
window_sums = np.add.reduceat(values, window_ixs.ravel())[::2]
window_sizes = np.diff(window_ixs, axis=1).ravel()
# convert window_sizes of 0 to 1 (inplace) to prevent division by zero
np.clip(window_sizes, 1, None, out=window_sizes)
out_values = np.stack(window_sums / window_sizes.reshape((-1, 1)))
return out_times.reshape((-1, 1)), out_values
class _DifficultyHitObject:
"""An object used to accumulate the strain information for calculating
stars.
Parameters
----------
hit_object : HitObject
The hit object to wrap.
radius : int
The circle radius
previous : _DifficultyHitObject, optional
The previous difficulty hit object.
"""
decay_base = 0.3, 0.15
almost_diameter = 90
stream_spacing = 110
single_spacing = 125
weight_scaling = 1400, 26.25
circle_size_buffer_threshold = 30
@unique
class Strain(IntEnum):
"""Indices for the strain specific values.
"""
speed = 0
aim = 1
def __init__(self, hit_object, radius, previous=None):
self.hit_object = hit_object
scaling_factor = 52 / radius
if radius < self.circle_size_buffer_threshold:
scaling_factor *= 1 + min(
self.circle_size_buffer_threshold - radius,
5,
) / 50
# this currently ignores slider length
self.normalized_start = self.normalized_end = Position(
hit_object.position.x * scaling_factor,
hit_object.position.y * scaling_factor,
)
if previous is None:
self.strains = 0, 0
else:
self.strains = (
self._calculate_strain(previous, self.Strain.speed),
self._calculate_strain(previous, self.Strain.aim),
)
def _calculate_strain(self, previous, strain):
result = 0
scaling = self.weight_scaling[strain]
hit_object = self.hit_object
if isinstance(hit_object, (Circle, Slider)):
result = self._spacing_weight(
self._distance(previous),
strain
) * scaling
time_elapsed = (
self.hit_object.time -
previous.hit_object.time
).total_seconds() * 1000
result /= max(time_elapsed, 50)
decay = (
self.decay_base[strain] **
(time_elapsed / 1000)
)
return previous.strains[strain] * decay + result
def _distance(self, previous):
"""The magnitude of distance between the current object and the
previous.
Parameters
----------
previous : _DifficultyHitObject
The previous difficulty hit object.
Returns
-------
distance : float
The absolute difference between the two hit objects.
"""
start = self.normalized_start
end = previous.normalized_end
return np.sqrt((start.x - end.x) ** 2 + (start.y - end.y) ** 2)
def _spacing_weight(self, distance, strain):
if strain == self.Strain.speed:
if distance > self.single_spacing:
return 2.5
elif distance > self.stream_spacing:
return (
1.6 +
0.9 *
(distance - self.stream_spacing) /
(self.single_spacing - self.stream_spacing)
)
elif distance > self.almost_diameter:
return (
1.2 +
0.4 *
(distance - self.almost_diameter) /
(self.stream_spacing - self.almost_diameter)
)
elif distance > self.almost_diameter / 2:
return (
0.95 +
0.25 *
(distance - self.almost_diameter / 2) /
(self.almost_diameter / 2.0)
)
return 0.95
return distance ** 0.99
[docs]class Beatmap:
"""A beatmap for osu! standard.
Parameters
----------
format_version : int
The version of the beatmap file.
audio_filename : str
The location of the audio file relative to the unpacked ``.osz``
directory.
audio_lead_in : timedelta
The amount of time added before the audio file begins playing. Useful
selection menu.
preview_time : timedelta
When the audio file should begin playing when selected in the song for
audio files that begin immediately.
countdown : bool
Should the countdown be displayed before the first hit object.
sample_set : str
The set of hit sounds to use through the beatmap.
stack_leniency : float
How often closely placed hit objects will be placed together.
mode : GameMode
The game mode.
letterbox_in_breaks : bool
Should the letterbox appear during breaks.
widescreen_storyboard : bool
Should the storyboard be widescreen?
bookmarks : list[timedelta]
The time for all of the bookmarks.
distance_spacing : float
A multiplier for the 'distance snap' feature.
beat_divisor : int
The beat division for placing objects.
grid_size : int
The size of the grid for the 'grid snap' feature.
timeline_zoom : float
The zoom in the editor timeline.
title : str
The title of the song limited to ascii characters.
title_unicode : str
The title of the song with unicode support.
artist : str
The name of the song artist limited to ascii characters.
artist_unicode : str
The name of the song artist with unicode support.
creator : str
The username of the mapper.
version : str
The name of the beatmap's difficulty.
source : str
The origin of the song.
tags : list[str]
A collection of words describing the song. This is searchable on the
osu! website.
beatmap_id : int or None
The id of this single beatmap. Old beatmaps did not store this in the
file.
beatmap_set_id : int or None
The id of this beatmap set. Old beatmaps did not store this in the
file.
hp_drain_rate : float
The ``HP`` attribute of the beatmap.
circle_size, : float
The ``CS`` attribute of the beatmap.
overall_difficulty : float
The ``OD`` attribute of the beatmap.
approach_rate : float
The ``AR`` attribute of the beatmap.
slider_multiplier : float
The multiplier for slider velocity.
slider_tick_rate : float
How often slider ticks appear.
timing_points : list[TimingPoint]
The timing points the the map.
hit_objects : list[HitObject]
The hit objects in the map.
Notes
-----
This is currently missing the storyboard data.
"""
_version_regex = re.compile(r'^osu file format v(\d+)$')
def __init__(self,
format_version,
audio_filename,
audio_lead_in,
preview_time,
countdown,
sample_set,
stack_leniency,
mode,
letterbox_in_breaks,
widescreen_storyboard,
bookmarks,
distance_spacing,
beat_divisor,
grid_size,
timeline_zoom,
title,
title_unicode,
artist,
artist_unicode,
creator,
version,
source,
tags,
beatmap_id,
beatmap_set_id,
hp_drain_rate,
circle_size,
overall_difficulty,
approach_rate,
slider_multiplier,
slider_tick_rate,
timing_points,
hit_objects):
self.format_version = format_version
self.audio_filename = audio_filename
self.audio_lead_in = audio_lead_in
self.preview_time = preview_time
self.countdown = countdown
self.sample_set = sample_set
self.stack_leniency = stack_leniency
self.mode = mode
self.letterbox_in_breaks = letterbox_in_breaks
self.widescreen_storyboard = widescreen_storyboard
self.bookmarks = bookmarks
self.distance_spacing = distance_spacing
self.beat_divisor = beat_divisor
self.grid_size = grid_size
self.timeline_zoom = timeline_zoom
self.title = title
self.title_unicode = title_unicode
self.artist = artist
self.artist_unicode = artist_unicode
self.creator = creator
self.version = version
self.source = source
self.tags = tags
self.beatmap_id = beatmap_id
self.beatmap_set_id = beatmap_set_id
self.hp_drain_rate = hp_drain_rate
self.circle_size = circle_size
self.overall_difficulty = overall_difficulty
self.approach_rate = approach_rate
self.slider_multiplier = slider_multiplier
self.slider_tick_rate = slider_tick_rate
self.timing_points = timing_points
self.hit_objects = hit_objects
# cache the stars with different mod combinations
self._stars_cache = {}
self._aim_stars_cache = {}
self._speed_stars_cache = {}
self._rhythm_awkwardness_cache = {}
@property
def display_name(self):
"""The name of the map as it appears in game.
"""
return f'{self.artist} - {self.title} [{self.version}]'
@memoize
[docs] def bpm_min(self, *, half_time=False, double_time=False):
"""The minimum BPM in this beatmap.
Parameters
----------
half_time : bool
The BPM with half time enabled.
double_time : bool
The BPM with double time enabled.
Returns
-------
bpm : float
The minimum BPM in this beatmap.
"""
bpm = min(p.bpm for p in self.timing_points if p.bpm)
if double_time:
bpm *= 1.5
elif half_time:
bpm *= 0.75
return bpm
@memoize
[docs] def bpm_max(self, *, half_time=False, double_time=False):
"""The maximum BPM in this beatmap.
Parameters
----------
half_time : bool
The BPM with half time enabled.
double_time : bool
The BPM with double time enabled.
Returns
-------
bpm : float
The maximum BPM in this beatmap.
"""
bpm = max(p.bpm for p in self.timing_points if p.bpm)
if double_time:
bpm *= 1.5
elif half_time:
bpm *= 0.75
return bpm
[docs] def hp(self, *, easy=False, hard_rock=False):
"""Compute the Health Drain (HP) value for different mods.
Parameters
----------
easy : bool, optional
HP with the easy mod enabled.
hard_rock : bool, optional
HP with the hard rock mod enabled.
Returns
-------
hp : float
The HP value.
"""
hp = self.circle_size
if hard_rock:
hp = min(1.4 * hp, 10)
elif easy:
hp /= 2
return hp
[docs] def cs(self, *, easy=False, hard_rock=False):
"""Compute the Circle Size (CS) value for different mods.
Parameters
----------
easy : bool, optional
CS with the easy mod enabled.
hard_rock : bool, optional
CS with the hard rock mod enabled.
Returns
-------
cs : float
The CS value.
"""
cs = self.circle_size
if hard_rock:
cs = min(1.4 * cs, 10)
elif easy:
cs /= 2
return cs
[docs] def od(self,
*,
easy=False,
hard_rock=False,
half_time=False,
double_time=False):
"""Compute the Overall Difficulty (OD) value for different mods.
Parameters
----------
easy : bool, optional
OD with the easy mod enabled.
hard_rock : bool, optional
OD with the hard rock mod enabled.
half_time : bool, optional
Effective OD with the half time mod enabled.
double_time : bool, optional
Effective OD with the double time mod enabled.
Returns
-------
od : float
The OD value.
"""
od = self.overall_difficulty
if hard_rock:
od = min(1.4 * od, 10)
elif easy:
od /= 2
if double_time:
od = ms_300_to_od(2 * od_to_ms_300(od) / 3)
elif half_time:
od = ms_300_to_od(4 * od_to_ms_300(od) / 3)
return od
[docs] def ar(self,
*,
easy=False,
hard_rock=False,
half_time=False,
double_time=False):
"""Compute the Approach Rate (AR) value for different mods.
Parameters
----------
easy : bool, optional
AR with the easy mod enabled.
hard_rock : bool, optional
AR with the hard rock mod enabled.
half_time : bool, optional
Effective AR with the half time mod enabled.
double_time : bool, optional
Effective AR with the double time mod enabled.
Returns
-------
ar : float
The effective AR value.
Notes
-----
``double_time`` and ``half_time`` do not actually affect the in game
AR; however, because the map is sped up or slowed down, the effective
approach rate is changed.
"""
ar = self.approach_rate
if easy:
ar /= 2
elif hard_rock:
ar = min(1.4 * ar, 10)
if double_time:
ar = ms_to_ar(2 * ar_to_ms(ar) / 3)
elif half_time:
ar = ms_to_ar(4 * ar_to_ms(ar) / 3)
return ar
@lazyval
def hit_objects_no_spinners(self):
"""The hit objects with spinners filtered out.
"""
return tuple(e for e in self.hit_objects if not isinstance(e, Spinner))
@lazyval
def circles(self):
"""Just the circles in the beatmap.
"""
return tuple(e for e in self.hit_objects if isinstance(e, Circle))
@lazyval
def max_combo(self):
"""The highest combo that can be achieved on this beatmap.
"""
max_combo = 0
for hit_object in self.hit_objects:
if isinstance(hit_object, Slider):
max_combo += hit_object.ticks
else:
max_combo += 1
return max_combo
def __repr__(self):
return f'<{type(self).__qualname__}: {self.display_name}>'
@classmethod
[docs] def from_osz_path(cls, path):
"""Read a beatmap collection from an ``.osz`` file on disk.
Parameters
----------
path : str or pathlib.Path
The file path to read from.
Returns
-------
beatmaps : dict[str, Beatmap]
A mapping from difficulty name to the parsed Beatmap.
Raises
------
ValueError
Raised when the file cannot be parsed as a ``.osz`` file.
"""
with ZipFile(path) as zf:
return cls.from_osz_file(zf)
@classmethod
[docs] def from_path(cls, path):
"""Read in a ``Beatmap`` object from a file on disk.
Parameters
----------
path : str or pathlib.Path
The path to the file to read from.
Returns
-------
beatmap : Beatmap
The parsed beatmap object.
Raises
------
ValueError
Raised when the file cannot be parsed as a ``.osu`` file.
"""
with open(path, encoding='utf-8-sig') as file:
return cls.from_file(file)
@classmethod
[docs] def from_osz_file(cls, file):
"""Read a beatmap collection from a ``.osz`` file on disk.
Parameters
----------
file : zipfile.ZipFile
The zipfile to read from.
Returns
-------
beatmaps : dict[str, Beatmap]
A mapping from difficulty name to the parsed Beatmap.
Raises
------
ValueError
Raised when the file cannot be parsed as a ``.osz`` file.
"""
return {
beatmap.version: beatmap
for beatmap in (
Beatmap.parse(file.read(name).decode('utf-8-sig'))
for name in
file.namelist() if name.endswith('.osu')
)
}
@classmethod
[docs] def from_file(cls, file):
"""Read in a ``Beatmap`` object from an open file object.
Parameters
----------
file : file-like
The file object to read from.
Returns
-------
beatmap : Beatmap
The parsed beatmap object.
Raises
------
ValueError
Raised when the file cannot be parsed as a ``.osu`` file.
"""
return cls.parse(file.read())
_mapping_groups = frozenset({
'General',
'Editor',
'Metadata',
'Difficulty',
})
@classmethod
def _find_groups(cls, lines):
"""Split the input data into the named groups.
Parameters
----------
lines : iterator[str]
The raw lines from the file.
Returns
-------
groups : dict[str, list[str] or dict[str, str]]
The lines in the section. If the section is a mapping section
the the value will be a dict from key to value.
"""
groups = {}
current_group = None
group_buffer = []
def commit_group():
nonlocal group_buffer
if current_group is None:
# we are not building a group, just return
return
# we are currently building a group
if current_group in cls._mapping_groups:
# build a dict from the ``Key: Value`` line format.
mapping = {}
for line in group_buffer:
split = line.split(':', 1)
try:
key, value = split
except ValueError:
key = split[0]
value = ''
# throw away whitespace
mapping[key.strip()] = value.strip()
group_buffer = mapping
groups[current_group] = group_buffer
group_buffer = []
for line in lines:
if not line or line.startswith('//'):
# filter out empty lines and comments
continue
if line[0] == '[' and line[-1] == ']':
# we found a section header, commit the current buffered group
# and start the new group
commit_group()
current_group = line[1:-1]
else:
group_buffer.append(line)
# commit the final group
commit_group()
return groups
@classmethod
[docs] def parse(cls, data):
"""Parse a ``Beatmap`` from text in the ``.osu`` format.
Parameters
----------
data : str
The data to parse.
Returns
-------
beatmap : Beatmap
The parsed beatmap object.
Raises
------
ValueError
Raised when the data cannot be parsed in the ``.osu`` format.
"""
data = data.lstrip()
lines = iter(data.splitlines())
line = next(lines)
match = cls._version_regex.match(line)
if match is None:
raise ValueError(f'missing osu file format specifier in: {line!r}')
format_version = int(match.group(1))
groups = cls._find_groups(lines)
artist = _get_as_str(groups, 'Metadata', 'Artist')
title = _get_as_str(groups, 'Metadata', 'Title')
od = _get_as_float(
groups,
'Difficulty',
'OverallDifficulty',
)
timing_points = []
# the parent starts as None because the first timing point should
# not be inherited
parent = None
for raw_timing_point in groups['TimingPoints']:
timing_point = TimingPoint.parse(raw_timing_point, parent)
if timing_point.parent is None:
# we have a new parent node, pass that along to the new
# timing points
parent = timing_point
timing_points.append(timing_point)
slider_multiplier = _get_as_float(
groups,
'Difficulty',
'SliderMultiplier',
default=1.4, # taken from wiki
)
slider_tick_rate = _get_as_float(
groups,
'Difficulty',
'SliderTickRate',
default=1.0, # taken from wiki
)
return cls(
format_version=format_version,
audio_filename=_get_as_str(groups, 'General', 'AudioFilename'),
audio_lead_in=timedelta(
milliseconds=_get_as_int(groups, 'General', 'AudioLeadIn', 0),
),
preview_time=timedelta(
milliseconds=_get_as_int(groups, 'General', 'PreviewTime'),
),
countdown=_get_as_bool(groups, 'General', 'Countdown', False),
sample_set=_get_as_str(groups, 'General', 'SampleSet'),
stack_leniency=_get_as_float(
groups,
'General',
'StackLeniency',
0,
),
mode=GameMode(_get_as_int(groups, 'General', 'Mode', 0)),
letterbox_in_breaks=_get_as_bool(
groups,
'General',
'LetterboxInBreaks',
False,
),
widescreen_storyboard=_get_as_bool(
groups,
'General',
'WidescreenStoryboard',
False,
),
bookmarks=[
timedelta(milliseconds=ms) for ms in _get_as_int_list(
groups,
'Editor',
'bookmarks',
[],
)
],
distance_spacing=_get_as_float(
groups,
'Editor',
'DistanceSpacing',
1,
),
beat_divisor=_get_as_int(groups, 'Editor', 'BeatDivisor', 4),
grid_size=_get_as_int(groups, 'Editor', 'GridSize', 4),
timeline_zoom=_get_as_float(groups, 'Editor', 'TimelineZoom', 1.0),
title=title,
title_unicode=_get_as_str(
groups,
'Metadata',
'TitleUnicode',
title,
),
artist=artist,
artist_unicode=_get_as_str(
groups,
'Metadata',
'ArtistUnicode',
artist,
),
creator=_get_as_str(groups, 'Metadata', 'Creator'),
version=_get_as_str(groups, 'Metadata', 'Version'),
source=_get_as_str(groups, 'Metadata', 'Source', None),
# space delimited list
tags=_get_as_str(groups, 'Metadata', 'Tags', '').split(),
beatmap_id=_get_as_int(groups, 'Metadata', 'BeatmapID', None),
beatmap_set_id=_get_as_int(
groups,
'Metadata',
'BeatmapSetID',
None,
),
hp_drain_rate=_get_as_float(groups, 'Difficulty', 'HPDrainRate'),
circle_size=_get_as_float(groups, 'Difficulty', 'CircleSize'),
overall_difficulty=_get_as_float(
groups,
'Difficulty',
'OverallDifficulty',
),
approach_rate=_get_as_float(
groups,
'Difficulty',
'ApproachRate',
# old maps didn't have an AR so the OD is used as a default
default=od,
),
slider_multiplier=slider_multiplier,
slider_tick_rate=slider_tick_rate,
timing_points=timing_points,
hit_objects=list(map(
partial(
HitObject.parse,
timing_points=timing_points,
slider_multiplier=slider_multiplier,
slider_tick_rate=slider_tick_rate,
),
groups['HitObjects'],
)),
)
[docs] def timing_point_at(self, time):
"""Get the :class:`slider.beatmap.TimingPoint` at the given time.
Parameters
----------
time : datetime.timedelta
The time to lookup the :class:`slider.beatmap.TimingPoint` for.
Returns
-------
timing_point : TimingPoint
The :class:`slider.beatmap.TimingPoint` at the given time.
"""
for tp in reversed(self.timing_points):
if tp.offset <= time:
return tp
return self.timing_points[0]
@staticmethod
def _base_strain(strain):
"""Scale up the base attribute
"""
return ((5 * np.maximum(1, strain / 0.0675) - 4) ** 3) / 100000
@staticmethod
def _handle_group(group):
inner = range(1, len(group))
for n in range(len(group)):
for m in inner:
if n == m:
continue
a = group[n]
b = group[m]
ratio = a / b if a > b else b / a
closest_power_of_two = 2 ** round(np.log2(ratio))
offset = (
abs(closest_power_of_two - ratio) /
closest_power_of_two
)
yield offset ** 2
_strain_step = timedelta(milliseconds=400)
_decay_weight = 0.9
def _calculate_difficulty(self, strain, difficulty_hit_objects):
highest_strains = []
append_highest_strain = highest_strains.append
strain_step = self._strain_step
interval_end = strain_step
max_strain = 0
previous = None
for difficulty_hit_object in difficulty_hit_objects:
while difficulty_hit_object.hit_object.time > interval_end:
append_highest_strain(max_strain)
if previous is None:
max_strain = 0
else:
decay = (
_DifficultyHitObject.decay_base[strain] ** (
interval_end -
previous.hit_object.time
).total_seconds()
)
max_strain = previous.strains[strain] * decay
interval_end += strain_step
max_strain = max(max_strain, difficulty_hit_object.strains[strain])
previous = difficulty_hit_object
difficulty = 0
weight = 1
decay_weight = self._decay_weight
for strain in sorted(highest_strains, reverse=True):
difficulty += weight * strain
weight *= decay_weight
return difficulty
_star_scaling_factor = 0.0675
_extreme_scaling_factor = 0.5
@staticmethod
def _product_no_diagonal(sequence):
"""An iterator of the Cartesian product of ``sequence`` with itself
with the diagonals removed.
Parameters
----------
sequence : sequence
The sequence to take the product with.
Yields
------
pair : (any, any)
The element of the product which is not on the diagonal.
"""
inner = range(1, len(sequence))
for n in range(len(sequence)):
for m in inner:
if n == m:
continue
yield sequence[n], sequence[m]
[docs] def hit_object_difficulty(self,
*,
easy=False,
hard_rock=False,
double_time=False,
half_time=False):
"""Compute the difficulty of each hit object.
Parameters
----------
easy : bool
Compute difficulty with easy.
hard_rock : bool
Compute difficulty with hard rock.
double_time : bool
Compute difficulty with double time.
half_time : bool
Compute difficulty with half time.
Returns
----------
times : np.ndarray
Single column array of times as ``timedelta64[ns]``
difficulties : np.ndarray
Array of difficulties as ``float64``. Speed in the first column,
aim in the second.
"""
cs = self.cs()
# NOTE: This is different than normal conversion
if hard_rock:
cs *= 1.3
elif easy:
cs /= 2
radius = circle_radius(cs)
if double_time:
modify = op.attrgetter('double_time')
elif half_time:
modify = op.attrgetter('half_time')
else:
def modify(e):
return e
times = np.empty(
(len(self.hit_objects) - 1, 1),
dtype='timedelta64[ns]',
)
strains = np.empty((len(self.hit_objects) - 1, 2), dtype=np.float64)
hit_objects = map(modify, self.hit_objects)
previous = _DifficultyHitObject(next(hit_objects), radius)
for i, hit_object in enumerate(hit_objects):
new = _DifficultyHitObject(
hit_object,
radius,
previous,
)
times[i] = hit_object.time
strains[i] = new.strains
previous = new
return times, strains
[docs] def smoothed_difficulty(self,
smoothing_window,
num_points,
*,
easy=False,
hard_rock=False,
double_time=False,
half_time=False):
"""Calculate a smoothed difficulty at evenly spaced points in time
between the beginning of the song and the last hit object of the map.
Done by taking an average of difficulties of hit objects within a
certain time window of each point
Useful if you want to calculate a difficulty curve for the map
since the unsmoothed values vary locally a lot.
Parameters
----------
smoothing_window : int or float
Time window (in seconds) for the moving average.
Bigger will make a smoother curve.
num_points : int
Number of points to calculate the average at.
easy : bool
Compute difficulty with easy.
hard_rock : bool
Compute difficulty with hard rock.
double_time : bool
Compute difficulty with double time.
half_time : bool
Compute difficulty with half time.
Returns
----------
difficulties : array
2D array containing smoothed time, difficulty pairs.
"""
times, values = self.hit_object_difficulty(
easy=easy,
hard_rock=hard_rock,
double_time=double_time,
half_time=half_time
)
return _moving_average_by_time(
times,
values,
smoothing_window,
num_points,
)
def _calculate_stars(self,
easy,
hard_rock,
double_time,
half_time):
"""Compute the stars and star components for this map.
Parameters
----------
easy : bool
Compute stars with easy.
hard_rock : bool
Compute stars with hard rock.
double_time : bool
Compute stars with double time.
half_time : bool
Compute stars with half time.
"""
cs = self.cs()
# NOTE: This is different than normal conversion
if hard_rock:
cs *= 1.3
elif easy:
cs /= 2
radius = circle_radius(cs)
difficulty_hit_objects = []
append_difficulty_hit_object = difficulty_hit_objects.append
intervals = []
append_interval = intervals.append
if double_time:
modify = op.attrgetter('double_time')
elif half_time:
modify = op.attrgetter('half_time')
else:
def modify(e):
return e
hit_objects = map(modify, self.hit_objects)
previous = _DifficultyHitObject(next(hit_objects), radius)
append_difficulty_hit_object(previous)
for hit_object in hit_objects:
new = _DifficultyHitObject(
hit_object,
radius,
previous,
)
append_interval(new.hit_object.time - previous.hit_object.time)
append_difficulty_hit_object(new)
previous = new
group = []
append_group_member = group.append
clear_group = group.clear
# todo: compute break time from ar
break_threshhold = timedelta(milliseconds=1200)
count_offsets = 0
rhythm_awkwardness = 0
for interval in intervals:
is_break = interval >= break_threshhold
if not is_break:
append_group_member(interval)
if is_break or len(group) == 5:
for awk in self._handle_group(group):
count_offsets += 1
rhythm_awkwardness += awk
clear_group()
for awk in self._handle_group(group):
count_offsets += 1
rhythm_awkwardness += awk
rhythm_awkwardness /= count_offsets or 1
rhythm_awkwardness *= 82
aim = self._calculate_difficulty(
_DifficultyHitObject.Strain.aim,
difficulty_hit_objects,
)
speed = self._calculate_difficulty(
_DifficultyHitObject.Strain.speed,
difficulty_hit_objects,
)
key = easy, hard_rock, double_time, half_time
self._aim_stars_cache[key] = aim = (
np.sqrt(aim) * self._star_scaling_factor
)
self._speed_stars_cache[key] = speed = (
np.sqrt(speed) * self._star_scaling_factor
)
self._stars_cache[key] = (
aim +
speed +
abs(speed - aim) *
self._extreme_scaling_factor
)
self._rhythm_awkwardness_cache[key] = rhythm_awkwardness
def _stars_cache_value(name, doc):
"""Create a cached function from pulling from the values generated
in ``_calculate_stars``.
Parameters
----------
name : str
The name of the attribute.
doc : str
The docstring for the attribute.
Returns
-------
getter : function
The getter function.
"""
cache_name = f'_{name}_cache'
def get(self,
*,
easy=False,
hard_rock=False,
double_time=False,
half_time=False):
key = (
bool(easy),
bool(hard_rock),
bool(double_time),
bool(half_time),
)
try:
return getattr(self, cache_name)[key]
except KeyError:
self._calculate_stars(*key)
return getattr(self, cache_name)[key]
get.__name__ = name
get.__doc__ = doc
return get
speed_stars = _stars_cache_value(
'speed_stars',
"""The speed part of the stars.
Parameters
----------
easy : bool, optional
Stars with the easy mod applied.
hard_rock : bool, optional
Stars with the hard rock mod applied.
double_time : bool, optional
Stars with the double time mod applied.
half_time : bool, optional
Stars with the half time mod applied.
Returns
-------
speed_stars : float
The aim component of the stars.
""",
)
aim_stars = _stars_cache_value(
'aim_stars',
"""The aim part of the stars.
Parameters
----------
easy : bool, optional
Stars with the easy mod applied.
hard_rock : bool, optional
Stars with the hard rock mod applied.
double_time : bool, optional
Stars with the double time mod applied.
half_time : bool, optional
Stars with the half time mod applied.
Returns
-------
aim_stars : float
The aim component of the stars.
""",
)
stars = _stars_cache_value(
'stars',
"""The stars as seen in osu!.
Parameters
----------
easy : bool, optional
Stars with the easy mod applied.
hard_rock : bool, optional
Stars with the hard rock mod applied.
double_time : bool, optional
Stars with the double time mod applied.
half_time : bool, optional
Stars with the half time mod applied.
Returns
-------
stars : float
The total stars for the map.
""",
)
rhythm_awkwardness = _stars_cache_value(
'rhythm_awkwardness',
"""The rhythm awkwardness component of the song.
Parameters
----------
easy : bool, optional
Rhythm awkwardness with the easy mod applied.
hard_rock : bool, optional
Rhythm awkwardness with the hard rock mod applied.
double_time : bool, optional
Rhythm awkwardness with the double time mod applied.
half_time : bool, optional
Rhythm awkwardness with the half time mod applied.
Returns
-------
rhythm_awkwardness : float
The rhythm awkwardness score.
""",
)
del _stars_cache_value
def _round_hitcounts(self, accuracy, count_miss=None):
"""Round the accuracy to the nearest hit counts.
Parameters
----------
accuracy : np.ndarray[float]
The accuracy to round in the range [0, 1]
count_miss : np.ndarray[int]int, optional
The number of misses to fix.
Returns
-------
count_300 : np.ndarray[int]
The number of 300s.
count_100 : np.ndarray[int]
The number of 100s.
count_50 : np.ndarray[int]
The number of 50s.
count_miss : np.ndarray[int]
The number of misses.
"""
if count_miss is None:
count_miss = np.full_like(accuracy, 0)
max_300 = len(self.hit_objects) - count_miss
accuracy = np.maximum(
0.0,
np.minimum(
calculate_accuracy(max_300, 0, 0, count_miss) * 100.0,
accuracy * 100,
),
)
count_50 = np.full_like(accuracy, 0)
count_100 = np.round(
-3.0 *
((accuracy * 0.01 - 1.0) * len(self.hit_objects) + count_miss) *
0.5,
)
mask = count_100 > len(self.hit_objects) - count_miss
count_100[mask] = 0
count_50[mask] = np.round(
-6.0 *
((accuracy[mask] * 0.01 - 1.0) * len(self.hit_objects) +
count_miss[mask]) *
0.2,
)
count_50[mask] = np.minimum(max_300[mask], count_50[mask])
count_100[~mask] = np.minimum(max_300[~mask], count_100[~mask])
count_300 = (
len(self.hit_objects) -
count_100 -
count_50 -
count_miss
)
return count_300, count_100, count_50, count_miss