Timing & note data
For advanced use cases that require parsing specific fields from simfiles and
charts, simfile provides subpackages that interface with the core
simfile & chart classes. As of version 2.0, the available subpackages are
simfile.notes
and simfile.timing
.
Reading note data
The primary function of a simfile is to store charts, and the primary function of a chart is to store note data – sequences of inputs that the player must follow to the rhythm of the song. Notes come in different types, appear in different columns, and occur on different beats of the chart.
Rather than trying to parse a chart’s notes
field directly,
use the NoteData
class:
>>> import simfile
>>> from simfile.notes import NoteData
>>> from simfile.timing import Beat
>>> springtime = simfile.open('testdata/Springtime/Springtime.ssc')
>>> chart = springtime.charts[0]
>>> note_data = NoteData(chart)
>>> note_data.columns
4
>>> for note in note_data:
... if note.beat > Beat(18): break
... print(note)
...
Note(beat=Beat(16), column=2, note_type=NoteType.TAP)
Note(beat=Beat(16.5), column=2, note_type=NoteType.TAP)
Note(beat=Beat(16.75), column=0, note_type=NoteType.TAP)
Note(beat=Beat(17), column=1, note_type=NoteType.TAP)
Note(beat=Beat(17.5), column=0, note_type=NoteType.TAP)
Note(beat=Beat(17.75), column=2, note_type=NoteType.TAP)
Note(beat=Beat(18), column=1, note_type=NoteType.TAP)
There’s no limit to how many notes a chart can contain – some have tens or even
hundreds of thousands! For this reason, NoteData
only generates
Note
objects when you ask for them, one at a time, rather than
storing a list of notes. Likewise, functions in this library that operate on
note data accept an iterator of notes, holding them in memory for as little
time as possible.
Counting notes
Counting notes isn’t as straightforward as it sounds: there are different note types and different ways to handle notes on the same beat. StepMania offers six different “counts” on the music selection screen by default, each offering a unique aggregation of the gameplay events in the chart.
To reproduce StepMania’s built-in note counts, use the functions provided by
the simfile.notes.count
module:
>>> import simfile
>>> from simfile.notes import NoteData
>>> from simfile.notes.count import *
>>> springtime = simfile.open('testdata/Springtime/Springtime.ssc')
>>> chart = springtime.charts[0]
>>> note_data = NoteData(chart)
>>> count_steps(note_data)
864
>>> count_jumps(note_data)
33
If the functions in this module aren’t sufficient for your needs, move on to the next section for more options.
Handling holds, rolls, and jumps
Conceptually, hold and roll notes are atomic: while they have discrete start and end beats, both endpoints must be specified for the note to be valid. This logic also extends to jumps in certain situations: for example, combo counters, judgement & score algorithms, and note counting methods may consider jumps to be “equal” in some sense to isolated tap notes.
In contrast, iterating over NoteData
yields a separate “note” for
every discrete event in the chart: hold and roll heads are separate from their
tails, and jumps are emitted one note at a time. You may want to group either
or both of these types of notes together, depending on your use case.
The group_notes()
function handles all of these cases. In this example,
we find that the longest hold in Springtime’s Lv. 21 chart is 6½ beats long:
>>> import simfile
>>> from simfile.notes import NoteType, NoteData
>>> from simfile.notes.group import OrphanedNotes, group_notes
>>> springtime = simfile.open('testdata/Springtime/Springtime.ssc')
>>> chart = next(filter(lambda chart: chart.meter == '21', springtime.charts))
>>> note_data = NoteData(chart)
>>> group_iterator = group_notes(
... note_data,
... include_note_types={NoteType.HOLD_HEAD, NoteType.TAIL},
... join_heads_to_tails=True,
... orphaned_tail=OrphanedNotes.DROP_ORPHAN,
... )
>>> longest_hold = 0
>>> for grouped_notes in group_iterator:
... note = note_group[0]
... longest_hold = max(longest_hold, note.tail_beat - note.beat)
...
>>> longest_hold
Fraction(13, 2)
There’s a lot going on in this code snippet, so here’s a breakdown of the important parts:
>>> group_iterator = group_notes(
... note_data,
... include_note_types={NoteType.HOLD_HEAD, NoteType.TAIL},
... orphaned_tail=OrphanedNotes.DROP_ORPHAN,
... join_heads_to_tails=True,
... )
Here we choose to group hold heads to their tails, dropping any orphaned tails.
By default, orphaned heads or tails will raise an exception, but in this
example we’ve opted out of including roll heads, whose tails would become
orphaned. If we chose to include NoteType.ROLL_HEAD
in the set, then
we could safely omit the orphaned_tail argument since all tails should
have a matching head (assuming the chart is valid).
>>> for grouped_notes in group_iterator:
... note = note_group[0]
... longest_hold = max(longest_hold, note.tail_beat - note.beat)
The group_notes()
function yields lists of notes rather than single
notes. In this example, every list will only have a single element because we
haven’t opted into joining notes that occur on the same beat (we would do so
using the same_beat_notes parameter). As such, we can extract the single note
by indexing into each note group.
You’ll notice that we’re using a tail_beat
attribute,
which isn’t present in the Note
class. That’s because these notes are
actually NoteWithTail
instances: the lists of notes referenced
above are actually lists of Note
and/or NoteWithTail
objects, depending on the parameters. In this case, we know that every note
will be a NoteWithTail
instance because we’ve only included head and
tail note types, which will be joined together.
Out of all the possible combinations of group_notes()
parameters, this
example yields fairly simple items (singleton lists of NoteWithTail
instances). Other combinations of parameters may yield variable-length lists
where you need to explicitly check the type of the elements.
Changing & writing notes
As mentioned before, the simfile.notes
API operates on iterators of
notes to keep the memory footprint light. Iterating over NoteData
is
one way to obtain a note iterator, but you can also generate Note
objects yourself.
To serialize a stream of notes into note data, use the class method
NoteData.from_notes()
:
>>> import simfile
>>> from simfile.notes import Note, NoteType, NoteData
>>> from simfile.timing import Beat
>>> cols = 4
>>> notes = [
... Note(beat=Beat(i, 2), column=i%cols, note_type=NoteType.TAP)
... for i in range(8)
... ]
>>> note_data = NoteData.from_notes(notes, cols)
>>> print(str(note_data))
1000
0100
0010
0001
1000
0100
0010
0001
The notes
variable above could use parentheses to define a generator
instead of square brackets to define a list, but you don’t have to stick to
pure generators to interact with the simfile.notes
API. Use whatever
data structure suits your use case, as long as you’re cognizant of potential
out-of-memory conditions.
Warning
Note iterators passed to the simfile.notes
API should always be
sorted by their natural ordering, the same order in which they appear in
strings of note data (and the order you’ll get by iterating over
NoteData
). If necessary, you can use Python’s built-in sorting
mechanisms on Note
objects to ensure they are in the right order,
like sorted()
, list.sort()
, and the bisect
module.
To insert note data back into a chart, convert it to a string and assign it
to the chart’s notes
attribute. In this example, we mirror
the notes’ columns in Springtime’s first chart and update the simfile object:
>>> import simfile
>>> from simfile.notes import NoteData
>>> from simfile.notes.count import *
>>> springtime = simfile.open('testdata/Springtime/Springtime.ssc')
>>> chart = springtime.charts[0]
>>> note_data = NoteData(chart)
>>> cols = note_data.columns
>>> def mirror(note, cols):
... return Note(
... beat=note.beat,
... column=cols - note.column - 1,
... note_type=note.note_type,
... )
...
>>> mirrored_notes = (mirror(note, cols) for note in note_data)
>>> mirrored_note_data = NoteData.from_notes(mirrored_notes, cols)
>>> chart.notes = str(mirrored_note_data)
From there, we could write the modified simfile back to disk as described in Reading & writing simfiles.
Reading timing data
Rather than reading fields like BPMS
and STOPS
directly from
the simfile, use the TimingData
class:
>>> import simfile
>>> from simfile.timing import TimingData
>>> springtime = simfile.open('testdata/Springtime/Springtime.ssc')
>>> timing_data = TimingData(springtime)
>>> timing_data.bpms
BeatValues([BeatValue(beat=Beat(0), value=Decimal('181.685'))])
The SSC format introduces “split timing” – per-chart timing data – which
TimingData
empowers you to handle as effortlessly as providing the
chart:
>>> import simfile
>>> from simfile.timing import TimingData
>>> springtime = simfile.open('testdata/Springtime/Springtime.ssc')
>>> chart = springtime.charts[0]
>>> split_timing = TimingData(springtime, chart)
>>> split_timing.bpms
BeatValues([BeatValue(beat=Beat(0), value=Decimal('181.685')), BeatValue(beat=Beat(304), value=Decimal('90.843')), BeatValue(beat=Beat(311), value=Decimal('181.685'))])
This works regardless of whether the chart has split timing, or even whether the simfile is an SSC file; if the chart has no timing data of its own, it will be ignored and the simfile’s timing data will be used instead.
Getting the displayed BPM
On StepMania’s music selection screen,
players can typically see the selected chart’s BPM,
whether static or a range of values.
For most charts, this is inferred through its timing data,
but the DISPLAYBPM tag can be used to override this value.
Additionally, the special DISPLAYBPM value *
obfuscates the BPM on the song selection screen,
typically with a flashing sequence of random numbers.
To get the displayed BPM, use the displaybpm()
function:
>>> import simfile
>>> from simfile.timing.displaybpm import displaybpm
>>> springtime = simfile.open('testdata/Springtime/Springtime.ssc')
>>> disp = displaybpm(springtime)
>>> if disp.value:
... print(f"Static value: {disp.value}")
... elif disp.range:
... print(f"Range of values: {disp.value[0]}-{disp.value[1]}")
... else:
... print(f"* (obfuscated BPM)")
...
Static value: 182
The return value will be one of
StaticDisplayBPM
, RangeDisplayBPM
, or RandomDisplayBPM
.
All of these classes implement four properties (as of 2.1):
StaticDisplayBPM.value
returns the single BPM value; the other classes return None.RangeDisplayBPM.range
returns a (min, max) tuple; the other classes return None.min
andmax
return the lowest and highest BPM values for bothStaticDisplayBPM
andRangeDisplayBPM
(they will be equal for the static case).
Here’s the same information in a table:
Actual BPM |
DISPLAYBPM value |
Class |
min |
max |
value |
range |
---|---|---|---|---|---|---|
300 |
|
300 |
300 |
300 |
None |
|
12-300 |
|
|
300 |
300 |
300 |
None |
12-300 |
|
12 |
300 |
None |
(12, 300) |
|
12-300 |
|
|
150 |
300 |
None |
(150, 300) |
12-300 |
|
|
None |
None |
None |
None |
Much like TimingData
,
displaybpm()
accepts an optional chart parameter for SSC split timing.
Also, setting ignore_specified to True will ignore any DISPLAYBPM property
and always return the true BPM range.
If you want to get the real BPM hidden by RandomDisplayBPM
(while allowing numeric DISPLAYBPM values),
you can do something like this:
disp = displaybpm(sf)
if not disp.max: # RandomDisplayBPM case
disp = displaybpm(sf, ignore_specified=True)
Warning
It may be tempting to use max
to calculate the scroll rate for MMod,
but this will be incorrect in some edge cases,
most notably songs with very high BPMs and no DISPLAYBPM specified.
Converting song time to beats
If you wanted to implement a simfile editor or gameplay engine, you’d need some way to convert song time to beats and vice-versa. To reach feature parity with StepMania, you’d need to implement BPM changes, stops, delays, and warps in order for your application to support all the simfiles that StepMania accepts.
Consider using the TimingEngine
for this use case:
>>> import simfile
>>> from simfile.timing import Beat, TimingData
>>> from simfile.timing.engine import TimingEngine
>>> springtime = simfile.open('testdata/Springtime/Springtime.ssc')
>>> timing_data = TimingData(springtime)
>>> engine = TimingEngine(timing_data)
>>> engine.time_at(Beat(32))
10.658
>>> engine.beat_at(10.658)
Beat(32)
This engine handles all of the timing events described above, including edge
cases involving overlapping stops, delays, and warps. You can even check
whether a note near a warp segment would be hittable()
or not!
Combining notes and time
Finally, to tie everything together, check out the time_notes()
function
which converts a Note
stream into a TimedNote
stream:
>>> import simfile
>>> from simfile.timing import Beat, TimingData
>>> from simfile.notes import NoteData
>>> from simfile.notes.timed import time_notes
>>> springtime = simfile.open('testdata/Springtime/Springtime.ssc')
>>> chart = springtime.charts[0]
>>> note_data = NoteData(chart)
>>> timing_data = TimingData(springtime, chart)
>>> for timed_note in time_notes(note_data, timing_data):
... if 60 < timed_note.time < 61:
... print(timed_note)
...
TimedNote(time=60.029, note=Note(beat=Beat(181.5), column=3, note_type=NoteType.TAP))
TimedNote(time=60.194, note=Note(beat=Beat(182), column=0, note_type=NoteType.HOLD_HEAD))
TimedNote(time=60.524, note=Note(beat=Beat(183), column=3, note_type=NoteType.TAP))
TimedNote(time=60.855, note=Note(beat=Beat(184), column=2, note_type=NoteType.TAP))
You could use this to determine the notes per second (NPS) over the entire chart, or at a specific time like the example above. Get creative!