Skip to content

Commit fe092b6

Browse files
committed
feat: implement tempo changer support
"Tempo Changer" is a hidden NBS feature that allows you to change tempo mid-song by renaming an instrument "Tempo Changer" and modulating the pitch of a note block with that instrument. (note pitch = new song BPM at that point)
1 parent 40d97f6 commit fe092b6

File tree

2 files changed

+56
-4
lines changed

2 files changed

+56
-4
lines changed

nbswave/main.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,16 @@ def missing_instruments(self):
109109
if instrument.id not in self._instruments
110110
]
111111

112-
def get_length(self, notes: Sequence[nbs.Note]) -> float:
112+
def get_length(
113+
self, notes: Sequence[nbs.Note], tempo_segments: Sequence[float]
114+
) -> float:
113115
"""Get the length of the exported track based on the last
114116
note to stop ringing.
115117
"""
116118

117119
def get_note_end_time(note: nbs.Note) -> float:
118120

119-
note_start = note.tick / self._song.header.tempo * 1000
121+
note_start = tempo_segments[note.tick]
120122
sound = self._instruments.get(note.instrument)
121123

122124
if sound is None: # Sound either missing or not assigned
@@ -138,7 +140,8 @@ def _mix(
138140
bit_depth: Optional[int] = 16,
139141
) -> audio.Track:
140142

141-
track_length = self.get_length(self._song.weighted_notes())
143+
tempo_segments = self._song.tempo_segments
144+
track_length = self.get_length(self._song.weighted_notes(), tempo_segments)
142145

143146
mixer = audio.Mixer(
144147
sample_width=bit_depth // 8,
@@ -205,7 +208,7 @@ def _mix(
205208
last_vol = vol
206209
last_pan = pan
207210

208-
pos = note.tick / self._song.header.tempo * 1000
211+
pos = tempo_segments[note.tick]
209212

210213
mixer.overlay(sound, position=pos)
211214

nbswave/nbs.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,55 @@ def duration(self) -> int:
111111
def duration(self) -> None:
112112
self._duration = len(self) / self.header.tempo * 1000
113113

114+
@property
115+
def tempo_changer_ids(self) -> List[int]:
116+
"""
117+
Return a list of all instruments which act as tempo changers.
118+
This is a hidden NBS feature.
119+
"""
120+
return [
121+
ins.id + self.header.default_instruments
122+
for ins in self.instruments
123+
if ins.name == "Tempo Changer"
124+
]
125+
126+
@property
127+
def has_tempo_changers(self) -> bool:
128+
"""Return true if this song contains any tempo changes."""
129+
tc_ids = self.tempo_changer_ids
130+
return tc_ids != [] and any(note.instrument in tc_ids for note in self.notes)
131+
132+
@property
133+
def tempo_segments(self) -> List[float]:
134+
"""
135+
Return a list with the same length as the number of ticks in the song,
136+
where each value is the point in milliseconds where that tick is played.
137+
"""
138+
tc_ids = self.tempo_changer_ids
139+
tempo_change_blocks = [note for note in self.notes if note.instrument in tc_ids]
140+
tempo_change_blocks.sort(key=lambda x: x.tick)
141+
current_tick = 0
142+
current_tempo = self.header.tempo
143+
tempo_segments = []
144+
millis = 0
145+
for note in tempo_change_blocks:
146+
# Edge case: if there are multiple tempo changers in the same tick,
147+
# the following will be a no-op, so only the first is considered
148+
for tick in range(current_tick, note.tick):
149+
millis += 1 / current_tempo * 1000
150+
tempo_segments.append(millis)
151+
current_tick = note.tick
152+
current_tempo = (
153+
note.pitch / 15
154+
) # The note pitch is the new BPM of the song (t/s = BPM / 15)
155+
156+
# Fill the remainder of the song (after the last tempo changer)
157+
for tick in range(current_tick, len(self) + 1):
158+
millis += 1 / current_tempo * 1000
159+
tempo_segments.append(millis)
160+
161+
return tempo_segments
162+
114163
def weighted_notes(self) -> Iterator[Note]:
115164
"""Return all notes in this song with their layer velocity and panning applied."""
116165
for note in self.notes:

0 commit comments

Comments
 (0)