This repository has been archived by the owner on Dec 5, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
note.py
339 lines (268 loc) · 11.2 KB
/
note.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
__all__ = [
"Note",
"get_notes",
"get_pitch",
]
import math
from dataclasses import dataclass
from typing import Any, Iterator, List, Tuple
import pynbs
NBS_DEFAULT_INSTRUMENTS = [
"block.note_block.harp",
"block.note_block.bass",
"block.note_block.basedrum",
"block.note_block.snare",
"block.note_block.hat",
"block.note_block.guitar",
"block.note_block.flute",
"block.note_block.bell",
"block.note_block.chime",
"block.note_block.xylophone",
"block.note_block.iron_xylophone",
"block.note_block.cow_bell",
"block.note_block.didgeridoo",
"block.note_block.bit",
"block.note_block.banjo",
"block.note_block.pling",
]
octaves = {
"harp": 0,
"bass": -2,
"basedrum": -1,
"snare": 1,
"hat": 0,
"guitar": -1,
"flute": 1,
"bell": 2,
"chime": 2,
"xylophone": 2,
"iron_xylophone": 0,
"cow_bell": 0,
"didgeridoo": -2,
"bit": 0,
"banjo": 0,
"pling": 0,
}
@dataclass
class Note:
"""Represents a note produced by a /playsound command."""
instrument: str = "block.note_block.harp"
volume: float = 1
radius: float = 16
pitch: float = 1
panning: float = 0
def play_speakers(self, stereo_separation: float = 4) -> str:
"""
Play a sound that can be heard in a small radius by all players in range.
"""
# This is achieved by bypassing the `volume` argument completely and instead using the
# target selector's `distance` argument to determine what players will be able to hear
# the song at all. Decay is achieved by using the `distance` argument to limit the range
# of the sound, with a base range and a rolloff factor that increases the audible range
# of notes according to its pitch (lower notes will be audible from further away).
#
# The regular value for volume in a /playsound command is 1.0 = 16 blocks. It's possible
# to increase it to increase the audible range (e.g. 2.0 = 32 blocks and so on), but
# decreasing it does *not* actually decrease the audible range, as you'd expect (e.g.
# 0.5 = 8 blocks). Instead, the sound is still audible within a 16-block range, but is
# softer overall.
#
# So, the only to achieve a gradual rolloff less than 16 blocks, is by entirely limiting
# who will be able to hear the songs at all via target selection. As such, we can use the
# `distance` condition to play notes only to players in a certain range. The code
# works with a base range of 9, adding ±3 blocks for lower and higher notes, giving an
# effective range between 6-12 blocks. This rolloff can be easily customized by tweaking
# the parameters of the sigmoid function used in the calculation. This creates a harsher
# decay/rolloff than using volume, but is necessary to achieve rolloff with a ranger smaller
# than 16 blocks.
def rolloff_curve(x: float) -> float:
# slope = -6 -> make curve steeper towards the center and mirror it in the x axis
# offset = -0.5 -> move the curve down so its center is at y=0
# scale = 6 -> scale the curve so it goes from -3 to 3 as x approaches +/-inf
# see: https://www.desmos.com/calculator/roidl8wnxl
return sigmoid(x, -6, -0.5, 6)
radius = 9 + rolloff_curve(self.radius)
stereo_offset = self.panning * stereo_separation // 2
position = f"^{stereo_offset} ^ ^"
return self.play(radius=radius, position=position, volume=self.volume)
def play_loudspeakers(self, stereo_separation: float = 8) -> str:
"""
Play a sound that can be heard in a large radius by all players in range.
"""
# This is achieved by using a large `volume` (sound will be audible at full volume
# inside a spherical range of `volume * 16` blocks) and setting `min_volume` to 0.
# The volume is multiplied by the `rolloff_factor` to make bass notes propagate further,
# giving the impression of the song 'fading' away as the player moves away from the source.
full_range = 32 # all notes will be audible at this range
decay_range = 48 # only bass notes will be audible at this range
min_volume = full_range // 16
max_volume = decay_range // 16
rolloff_factor = self.radius
target_volume = (
min_volume + (max_volume - min_volume) * linear(rolloff_factor, -0.5, 0.5)
) * self.volume
volume = target_volume
radius = decay_range
stereo_offset = self.panning * stereo_separation // 2
position = f"^{stereo_offset} ^ ^"
return self.play(
radius=radius,
volume=volume,
position=position,
)
def play_headphones(self):
"""
Play a sound that can be globally heard by players with headphones.
"""
# This is achieved by setting the `volume` to 0 (actual value is irrelevant) and,
# instead, using `min_volume` as the desired volume. This way it doesn't matter if
# the player is within the `volume`'s range - they will always hear it at `min_volume`.
# No custom rolloff is present here.
volume = self.volume
position = f"^{-self.panning} ^ ^"
return self.play(
volume=volume,
position=position,
selector="@s",
)
def play(
self,
radius: float | None = None,
tag: str | None = None,
source: str = "record",
position: str = "^ ^ ^",
volume: float = 1,
min_volume: float = 0,
selector: str = "@a",
):
"""Return the /playsound command to play the note for the given player."""
instrument = self.instrument.replace("/", "_")
selector_arguments = []
if radius is not None:
selector_arguments.append(f"distance=..{radius:.2f}")
if tag is not None:
selector_arguments.append(f"tag={tag}")
target_selector = f"{selector}[{','.join(selector_arguments)}]"
if self.pitch > 2:
# print("Warning pitch", self.pitch, "is larger than 2", source)
pitch = 2
else:
pitch = self.pitch
if min_volume > 1:
# print("Warning min_volume", min_volume, "is larger than 1", target_selector)
min_volume = 1
args = f"{instrument} {source} {target_selector} {position} {volume:.3f} {pitch:.5f} {min_volume:.3f}"
return args
def get_notes(song: pynbs.File) -> Iterator[Tuple[int, List["Note"]]]:
"""Yield all the notes from the given nbs file."""
# Quantize notes to nearest tick (pigstep always exports at 20 t/s)
# Remove vanilla instrument notes outside the 6-octave range
# Remove custom instrument notes outside the 2-octave range
new_notes = []
# Add special notes to mark the beats
# (we'll quantize the song afterwards so doing it later on would be out of sync)
for tick in range(0, song.header.song_length, 4):
song.notes.append(
pynbs.Note(
tick=tick,
layer=150,
key=45,
instrument=-1,
)
)
for note in song.notes:
new_tick = round(note.tick * 20 / song.header.tempo)
note.tick = new_tick
note_pitch = note.key + note.pitch / 100
is_custom_instrument = note.instrument >= song.header.default_instruments
is_2_octave = 33 <= note_pitch <= 57
is_6_octave = 9 <= note_pitch <= 81
if is_custom_instrument and not is_2_octave:
# print(
# f"Warning: Custom instrument out of 2-octave range at {note.tick},{note.layer}: {note_pitch}"
# )
continue
if not is_custom_instrument and not is_6_octave:
# print(
# f"Warning: Vanilla instrument out of 6-octave range at {note.tick},{note.layer}: {note_pitch}"
# )
continue
new_notes.append(note)
song.notes = new_notes
# Ensure that there are as many layers as the last layer with a note
max_layer = max(note.layer for note in song.notes)
while len(song.layers) <= max_layer:
song.layers.append(pynbs.Layer(id=len(song.layers)))
# Make sure instrument paths are valid
for instrument in song.instruments:
instrument.file = instrument.file.lower().replace(" ", "_")
if not instrument.file.startswith("minecraft/"):
print(f"Warning: Invalid instrument path: {instrument.file}")
sounds = NBS_DEFAULT_INSTRUMENTS + [
instrument.file.replace("minecraft/", "").replace(".ogg", "")
for instrument in song.instruments
]
def get_note(note: pynbs.Note) -> Note:
"""Get an intermediary note for /playsound based on a pynbs note."""
layer = song.layers[note.layer]
sound = sounds[note.instrument] if note.instrument >= 0 else "BEAT"
pitch = note.key + (note.pitch / 100)
octave_suffix = "_-1" if pitch < 33 else "_1" if pitch > 57 else ""
source = f"{sound}{octave_suffix}"
layer_volume = layer.volume / 100
note_volume = note.velocity / 100
instrument = sound.split(".")[-1]
volume = layer_volume * note_volume
radius = get_rolloff_factor(pitch, instrument)
panning = get_panning(note, layer)
pitch = get_pitch(note)
return Note(
instrument=source,
volume=volume,
radius=radius,
panning=panning,
pitch=pitch,
)
output = {}
for tick in range(0, song.header.song_length, 8):
output[tick] = []
for tick, chord in song:
if tick not in output:
output[tick] = []
output[tick].extend(get_note(note) for note in chord)
for tick, notes in output.items():
yield tick, notes
def get_panning(note: Any, layer: Any) -> float:
"""Get panning for a given nbs note."""
if layer.panning == 0:
pan = note.panning
else:
pan = (layer.panning + note.panning) / 2
pan /= 100
return pan
def get_pitch(note: Any) -> float:
"""Get pitch for a given nbs note."""
key = note.key + note.pitch / 100
if key < 33:
key -= 9
elif key > 57:
key -= 57
else:
key -= 33
return 2 ** (key / 12) / 2
def sigmoid(x: float, slope: float = 1, offset: float = 0, scale: float = 1) -> float:
return (1 / (1 + math.exp(-x * slope)) + offset) * scale
def linear(x: float, slope: float = 1, offset: float = 0) -> float:
return x * slope + offset
def get_rolloff_factor(pitch: float, instrument: str) -> float:
"""
Return the rolloff factor of a note, given its pitch and instrument.
The rolloff factor is a value between -1 and 1 that determines how far
the note can be heard. Its value is zero at the center of the 6-octave
range (45) and increases linearly towards the edges of the range.
"""
# Calculate true pitch taking into account each instrument's octave offset
real_pitch = pitch + 12 * octaves.get(instrument, 1)
# 45 is the middle point (33-57) of the 6-octave range, where the rolloff factor should be 0
factor = (real_pitch - 45) / (45 - 8)
return factor