shaderflow 0.4.0.dev1__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shaderflow might be problematic. Click here for more details.
- ShaderFlow/Common/Imgui.py +0 -0
- ShaderFlow/Common/Notes.py +134 -0
- ShaderFlow/Common/__init__.py +0 -0
- ShaderFlow/Message.py +147 -0
- ShaderFlow/Module.py +162 -0
- ShaderFlow/Modules/Audio.py +431 -0
- ShaderFlow/Modules/Bouncing.py +94 -0
- ShaderFlow/Modules/Camera.py +451 -0
- ShaderFlow/Modules/Depth.py +0 -0
- ShaderFlow/Modules/Dynamics.py +288 -0
- ShaderFlow/Modules/Frametimer.py +79 -0
- ShaderFlow/Modules/Keyboard.py +43 -0
- ShaderFlow/Modules/Noise.py +80 -0
- ShaderFlow/Modules/OpticalFlow.py +1 -0
- ShaderFlow/Modules/Piano.py +362 -0
- ShaderFlow/Modules/Spectrogram.py +315 -0
- ShaderFlow/Modules/Video.py +234 -0
- ShaderFlow/Modules/Waveform.py +100 -0
- ShaderFlow/Modules/__init__.py +0 -0
- ShaderFlow/Resources/Images/ShaderFlow.png +0 -0
- ShaderFlow/Resources/Images/ShaderFlow.svg +1 -0
- ShaderFlow/Resources/Scenes/Examples/Demo.py +284 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Bars.frag +20 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Bouncing.frag +50 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Life/Simulation.glsl +49 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Life/Visuals.glsl +31 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/MotionBlur.frag +20 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Multipass.frag +43 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/RayMarch.frag +59 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/ShaderToy.frag +65 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Video.frag +5 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Visualizer.frag +75 -0
- ShaderFlow/Resources/Scenes/Examples/GLSL/Waveform.frag +18 -0
- ShaderFlow/Resources/Shaders/Fragment/Default.glsl +65 -0
- ShaderFlow/Resources/Shaders/Fragment/Final.glsl +7 -0
- ShaderFlow/Resources/Shaders/Fragment/Loading.frag +40 -0
- ShaderFlow/Resources/Shaders/Fragment/Missing.glsl +23 -0
- ShaderFlow/Resources/Shaders/Include/Camera.glsl +166 -0
- ShaderFlow/Resources/Shaders/Include/Complex.glsl +0 -0
- ShaderFlow/Resources/Shaders/Include/ShaderFlow.glsl +444 -0
- ShaderFlow/Resources/Shaders/Vertex/Default.glsl +21 -0
- ShaderFlow/Scene.py +1124 -0
- ShaderFlow/Shader.py +391 -0
- ShaderFlow/Texture.py +528 -0
- ShaderFlow/Variable.py +128 -0
- ShaderFlow/__init__.py +11 -0
- ShaderFlow/__main__.py +22 -0
- shaderflow-0.4.0.dev1.dist-info/METADATA +79 -0
- shaderflow-0.4.0.dev1.dist-info/RECORD +51 -0
- shaderflow-0.4.0.dev1.dist-info/WHEEL +5 -0
- shaderflow-0.4.0.dev1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import itertools
|
|
3
|
+
import shutil
|
|
4
|
+
import struct
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections import deque
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Deque, Dict, Iterable, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import numpy
|
|
11
|
+
from attr import Factory, define
|
|
12
|
+
|
|
13
|
+
from Broken import BROKEN, BrokenPath, BrokenPlatform, BrokenSpinner, log, shell
|
|
14
|
+
from Broken.Externals.FFmpeg import BrokenFFmpeg
|
|
15
|
+
from Broken.Types import BPM, Seconds
|
|
16
|
+
from ShaderFlow.Common.Notes import BrokenPianoNote
|
|
17
|
+
from ShaderFlow.Module import ShaderModule
|
|
18
|
+
from ShaderFlow.Modules.Dynamics import DynamicNumber
|
|
19
|
+
from ShaderFlow.Texture import ShaderTexture
|
|
20
|
+
from ShaderFlow.Variable import ShaderVariable
|
|
21
|
+
|
|
22
|
+
MAX_CHANNELS = 32
|
|
23
|
+
MAX_ROLLING = 256
|
|
24
|
+
MAX_NOTE = 128
|
|
25
|
+
|
|
26
|
+
@define
|
|
27
|
+
class ShaderPiano(ShaderModule):
|
|
28
|
+
name: str = "iPiano"
|
|
29
|
+
"""Texture name prefixes for this Module"""
|
|
30
|
+
|
|
31
|
+
tempo: Deque[Tuple[Seconds, BPM]] = Factory(deque)
|
|
32
|
+
"""List of tempo changes at (seconds, bpm)"""
|
|
33
|
+
|
|
34
|
+
keys_texture: ShaderTexture = None
|
|
35
|
+
"""Velocities texture, X is MIDI index, Y is Velocity, size (MAX_NOTE, 1)"""
|
|
36
|
+
|
|
37
|
+
channel_texture: ShaderTexture = None
|
|
38
|
+
"""Channel being played texture, X is MIDI index, Y is Channel, size (MAX_NOTE, 1)"""
|
|
39
|
+
|
|
40
|
+
roll_texture: ShaderTexture = None
|
|
41
|
+
"""Piano roll'ling notes main texture'. The X coordinate is the MIDI index, pixels contains data
|
|
42
|
+
(start, end, channel, velocity), of each playing key on the Y. Size (MAX_ROLLING, MAX_NOTE)"""
|
|
43
|
+
|
|
44
|
+
time_offset: Seconds = 0
|
|
45
|
+
"""Offset the notes being played search from the current time"""
|
|
46
|
+
|
|
47
|
+
roll_time: Seconds = 2
|
|
48
|
+
"""How long the notes are visible, 'roll for'"""
|
|
49
|
+
|
|
50
|
+
height: float = 0.275
|
|
51
|
+
"""Height of the piano in the shader (0-1)"""
|
|
52
|
+
|
|
53
|
+
black_ratio: float = 0.6
|
|
54
|
+
"""How long are black keys compared to white keys"""
|
|
55
|
+
|
|
56
|
+
global_minimum_note: int = MAX_NOTE
|
|
57
|
+
"""The lowest note in the loaded notes"""
|
|
58
|
+
|
|
59
|
+
global_maximum_note: int = 0
|
|
60
|
+
"""The highest note in the loaded notes"""
|
|
61
|
+
|
|
62
|
+
extra_side_keys: int = 6
|
|
63
|
+
"""Display the dynamic range plus this many keys on each side"""
|
|
64
|
+
|
|
65
|
+
future_range_lookup: Seconds = 2
|
|
66
|
+
"""Lookup notes in (roll_time + this) for setting the dynamic ranges"""
|
|
67
|
+
|
|
68
|
+
release_before_end: Seconds = 0.05
|
|
69
|
+
"""Workaround for the transition between close/glued to be perceived"""
|
|
70
|
+
|
|
71
|
+
key_press_dynamics: DynamicNumber = Factory(lambda: DynamicNumber(
|
|
72
|
+
value=numpy.zeros(MAX_NOTE, numpy.float32),
|
|
73
|
+
frequency=4, zeta=0.4, response=0, precision=0
|
|
74
|
+
))
|
|
75
|
+
|
|
76
|
+
note_range_dynamics: DynamicNumber = Factory(lambda: DynamicNumber(
|
|
77
|
+
value=numpy.zeros(2, numpy.float32),
|
|
78
|
+
frequency=0.05, zeta=1/(2**0.5), response=0,
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
tree: Dict[int, Dict[int, Deque[BrokenPianoNote]]] = Factory(dict)
|
|
82
|
+
"""Internal data structure for storing the notes"""
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def lookup_time(self) -> Seconds:
|
|
86
|
+
"""The full lookup time we should care for future notes (rolling+future range)"""
|
|
87
|
+
return (self.roll_time + self.future_range_lookup)
|
|
88
|
+
|
|
89
|
+
# # Internal
|
|
90
|
+
|
|
91
|
+
def __post__(self):
|
|
92
|
+
self.keys_texture = ShaderTexture(scene=self.scene, name=f"{self.name}Keys").from_numpy(self._empty_keys())
|
|
93
|
+
self.channel_texture = ShaderTexture(scene=self.scene, name=f"{self.name}Chan").from_numpy(self._empty_keys())
|
|
94
|
+
self.roll_texture = ShaderTexture(scene=self.scene, name=f"{self.name}Roll").from_numpy(self._empty_roll())
|
|
95
|
+
self.tempo_texture = ShaderTexture(scene=self.scene, name=f"{self.name}Tempo").from_numpy(numpy.zeros((100, 1, 2), numpy.float32))
|
|
96
|
+
|
|
97
|
+
def _empty_keys(self) -> numpy.ndarray:
|
|
98
|
+
return numpy.zeros((MAX_NOTE, 1), numpy.float32)
|
|
99
|
+
|
|
100
|
+
def _empty_roll(self) -> numpy.ndarray:
|
|
101
|
+
return numpy.zeros((MAX_NOTE, MAX_ROLLING, 4), numpy.float32)
|
|
102
|
+
|
|
103
|
+
# # Data structure
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _ranges(start: Seconds, end: Seconds) -> Iterable[int]:
|
|
107
|
+
return range(int(start), int(end)+1)
|
|
108
|
+
|
|
109
|
+
def clear(self):
|
|
110
|
+
self.tree.clear()
|
|
111
|
+
|
|
112
|
+
def add_note(self, note: Optional[BrokenPianoNote]) -> None:
|
|
113
|
+
if note is None:
|
|
114
|
+
return
|
|
115
|
+
for index in self._ranges(note.start, note.end):
|
|
116
|
+
self.tree.setdefault(note.note, dict()).setdefault(index, deque()).append(note)
|
|
117
|
+
self.update_global_ranges(note.note)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def notes(self) -> Iterable[BrokenPianoNote]:
|
|
121
|
+
for block in self.tree.values():
|
|
122
|
+
for notes in block.values():
|
|
123
|
+
yield from notes
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def duration(self) -> float:
|
|
127
|
+
return max((note.end for note in self.notes), default=0)
|
|
128
|
+
|
|
129
|
+
def __iter__(self) -> Iterable[BrokenPianoNote]:
|
|
130
|
+
return self.notes
|
|
131
|
+
|
|
132
|
+
def notes_between(self, index: int, start: Seconds, end: Seconds) -> Iterable[BrokenPianoNote]:
|
|
133
|
+
exists = set()
|
|
134
|
+
for other in self._ranges(start, end):
|
|
135
|
+
for note in self.tree.get(index, dict()).get(other, deque()):
|
|
136
|
+
if (note.start > end):
|
|
137
|
+
continue
|
|
138
|
+
if (id(note) in exists):
|
|
139
|
+
continue
|
|
140
|
+
exists.add(id(note))
|
|
141
|
+
yield note
|
|
142
|
+
|
|
143
|
+
def update_global_ranges(self, note: int) -> None:
|
|
144
|
+
self.global_minimum_note = min(self.global_minimum_note, note)
|
|
145
|
+
self.global_maximum_note = max(self.global_maximum_note, note)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def maximum_velocity(self) -> Optional[int]:
|
|
149
|
+
return max((note.velocity for note in self.notes), default=None)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def minimum_velocity(self) -> Optional[int]:
|
|
153
|
+
return min((note.velocity for note in self.notes), default=None)
|
|
154
|
+
|
|
155
|
+
def normalize_velocities(self, minimum: int=100, maximum: int=100) -> None:
|
|
156
|
+
ma, mi = (self.maximum_velocity, self.minimum_velocity)
|
|
157
|
+
|
|
158
|
+
# Safe against (minimum-maximum=0)
|
|
159
|
+
def new(velocity: int) -> int:
|
|
160
|
+
if (ma==mi): return (maximum+minimum)//2
|
|
161
|
+
return int((velocity-mi)/(ma-mi)*(maximum-minimum)+minimum)
|
|
162
|
+
|
|
163
|
+
for note in self.notes:
|
|
164
|
+
note.velocity = new(note.velocity)
|
|
165
|
+
|
|
166
|
+
def load_midi(self, path: Path):
|
|
167
|
+
import pretty_midi
|
|
168
|
+
|
|
169
|
+
if not (path := BrokenPath(path)).exists():
|
|
170
|
+
log.warning(f"{self.who} Input Midi file not found ({path})")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
with BrokenSpinner(log.info(f"Loading Midi file at ({path})")):
|
|
174
|
+
midi = pretty_midi.PrettyMIDI(str(path))
|
|
175
|
+
for channel, instrument in enumerate(midi.instruments):
|
|
176
|
+
if instrument.is_drum:
|
|
177
|
+
pass
|
|
178
|
+
for note in instrument.notes:
|
|
179
|
+
self.add_note(BrokenPianoNote(
|
|
180
|
+
note=note.pitch,
|
|
181
|
+
start=note.start,
|
|
182
|
+
end=note.end,
|
|
183
|
+
channel=channel,
|
|
184
|
+
velocity=note.velocity,
|
|
185
|
+
))
|
|
186
|
+
# Add tempo changes
|
|
187
|
+
for when, tempo in zip(*midi.get_tempo_changes()):
|
|
188
|
+
self.tempo.append((when, tempo))
|
|
189
|
+
|
|
190
|
+
self.tempo_texture.clear()
|
|
191
|
+
|
|
192
|
+
for offset, (when, tempo) in enumerate(self.tempo):
|
|
193
|
+
self.tempo_texture.write(data=struct.pack("ff", when, tempo), viewport=(0, offset, 1, 1))
|
|
194
|
+
|
|
195
|
+
# # Core Logic
|
|
196
|
+
|
|
197
|
+
# A (MAX_MIDI Notes x MAX_CHANNELS Channels) matrix of the end-most note being played
|
|
198
|
+
_playing_matrix: List[List[Optional[BrokenPianoNote]]] = Factory(lambda: [[None]*MAX_CHANNELS for _ in range(MAX_NOTE)])
|
|
199
|
+
|
|
200
|
+
def update(self):
|
|
201
|
+
|
|
202
|
+
# Utilities and trackers
|
|
203
|
+
time = (self.scene.time + self.time_offset)
|
|
204
|
+
upcoming = set()
|
|
205
|
+
|
|
206
|
+
# # Get and update pressed keys
|
|
207
|
+
self.key_press_dynamics.target.fill(0)
|
|
208
|
+
roll = self._empty_roll()
|
|
209
|
+
|
|
210
|
+
# Channel '-1' means the note is not being played !
|
|
211
|
+
channels = (self._empty_keys() - 1)
|
|
212
|
+
|
|
213
|
+
# Optimization: No need to check for the entire range 😉
|
|
214
|
+
for midi in range(self.global_minimum_note, self.global_maximum_note+1):
|
|
215
|
+
simultaneous = 0
|
|
216
|
+
|
|
217
|
+
for note in self.notes_between(midi, time, time+self.lookup_time):
|
|
218
|
+
upcoming.add(midi)
|
|
219
|
+
|
|
220
|
+
# Ignore notes out of the viewport
|
|
221
|
+
if (note.start >= time+self.roll_time):
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# Build a 2D Grid of the piano keys being played
|
|
225
|
+
# • Coordinate: (Note, #offset) @ (Start, End, Channel, Velocity)
|
|
226
|
+
if (simultaneous < MAX_ROLLING):
|
|
227
|
+
roll[note.note, simultaneous] = (note.start, note.end, note.channel, note.velocity)
|
|
228
|
+
simultaneous += 1
|
|
229
|
+
|
|
230
|
+
# Skip non-playing notes
|
|
231
|
+
if not (note.start <= time <= note.end):
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# Workaround: Don't play the full note, so close notes velocities are perceived twice
|
|
235
|
+
_note_too_small = (note.end - note.start) < self.release_before_end
|
|
236
|
+
_shorter_note = (time < (note.end - self.release_before_end))
|
|
237
|
+
|
|
238
|
+
if (_shorter_note or _note_too_small):
|
|
239
|
+
self.key_press_dynamics.target[midi] = note.velocity
|
|
240
|
+
|
|
241
|
+
# Either way, the channel must be colored
|
|
242
|
+
channels[midi] = note.channel
|
|
243
|
+
|
|
244
|
+
# Find empty slots or notes that will end soon, replace and play
|
|
245
|
+
other = self._playing_matrix[midi][note.channel]
|
|
246
|
+
if (other is None) or (other.end > note.end):
|
|
247
|
+
play_velocity = int(128*((note.velocity/128)**0.5))
|
|
248
|
+
self.fluid_key_down(midi, play_velocity, note.channel)
|
|
249
|
+
self._playing_matrix[midi][note.channel] = note
|
|
250
|
+
|
|
251
|
+
# Find notes that are not being played
|
|
252
|
+
for channel in range(MAX_CHANNELS * self.scene.realtime):
|
|
253
|
+
if (other := self._playing_matrix[midi][channel]) and (other.end < time):
|
|
254
|
+
self._playing_matrix[midi][channel] = None
|
|
255
|
+
self.fluid_key_up(midi, other.channel)
|
|
256
|
+
|
|
257
|
+
# Dynamic zoom velocity based on future lookup
|
|
258
|
+
self.note_range_dynamics.frequency = 0.5/self.lookup_time
|
|
259
|
+
|
|
260
|
+
# Set dynamic note range to the globals on the start
|
|
261
|
+
if sum(self.note_range_dynamics.value) == 0:
|
|
262
|
+
self.note_range_dynamics.value[:] = (self.global_minimum_note, self.global_maximum_note)
|
|
263
|
+
|
|
264
|
+
# Set new targets for dynamic keys
|
|
265
|
+
self.note_range_dynamics.target[:] = (
|
|
266
|
+
min(upcoming, default=self.global_minimum_note),
|
|
267
|
+
max(upcoming, default=self.global_maximum_note)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Write to keys textures
|
|
271
|
+
self.note_range_dynamics.next(dt=abs(self.scene.dt))
|
|
272
|
+
self.key_press_dynamics.next(dt=abs(self.scene.dt))
|
|
273
|
+
self.keys_texture.write(data=self.key_press_dynamics.value)
|
|
274
|
+
self.roll_texture.write(data=roll)
|
|
275
|
+
self.channel_texture.write(data=channels)
|
|
276
|
+
|
|
277
|
+
def pipeline(self) -> Iterable[ShaderVariable]:
|
|
278
|
+
yield ShaderVariable("uniform", "int", f"{self.name}GlobalMin", self.global_minimum_note)
|
|
279
|
+
yield ShaderVariable("uniform", "int", f"{self.name}GlobalMax", self.global_maximum_note)
|
|
280
|
+
yield ShaderVariable("uniform", "vec2", f"{self.name}Dynamic", self.note_range_dynamics.value)
|
|
281
|
+
yield ShaderVariable("uniform", "float", f"{self.name}RollTime", self.roll_time)
|
|
282
|
+
yield ShaderVariable("uniform", "float", f"{self.name}Extra", self.extra_side_keys)
|
|
283
|
+
yield ShaderVariable("uniform", "float", f"{self.name}Height", self.height)
|
|
284
|
+
yield ShaderVariable("uniform", "int", f"{self.name}Limit", MAX_ROLLING)
|
|
285
|
+
yield ShaderVariable("uniform", "float", f"{self.name}BlackRatio", self.black_ratio)
|
|
286
|
+
|
|
287
|
+
# # Fluidsynth
|
|
288
|
+
|
|
289
|
+
fluidsynth: Any = None
|
|
290
|
+
soundfont: Any = None
|
|
291
|
+
|
|
292
|
+
def fluid_load(self, sf2: Path, driver: str=("pulseaudio" if BrokenPlatform.OnLinux else None)) -> None:
|
|
293
|
+
if not (sf2 := BrokenPath(sf2)).exists():
|
|
294
|
+
log.warning(f"{self.who} Couldn't load SoundFont from path ({sf2}), will not have Real Time MIDI Audio")
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
# Download FluidSynth for Windows
|
|
298
|
+
if BrokenPlatform.OnWindows:
|
|
299
|
+
FLUIDSYNTH = "https://github.com/FluidSynth/fluidsynth/releases/download/v2.3.4/fluidsynth-2.3.4-win10-x64.zip"
|
|
300
|
+
BrokenPath.add_to_path(BrokenPath.extract(BrokenPath.download(FLUIDSYNTH), BROKEN.DIRECTORIES.EXTERNALS), recurse=True)
|
|
301
|
+
elif BrokenPlatform.OnMacOS:
|
|
302
|
+
if not shutil.which("fluidsynth"):
|
|
303
|
+
shell("brew", "install", "fluidsynth")
|
|
304
|
+
elif BrokenPlatform.OnLinux:
|
|
305
|
+
log.minor(f"{self.who} Please install FluidSynth in your Package Manager if needed")
|
|
306
|
+
|
|
307
|
+
import fluidsynth
|
|
308
|
+
self.fluidsynth = fluidsynth.Synth()
|
|
309
|
+
with BrokenSpinner(log.info(f"Loading FluidSynth SoundFont ({sf2.name})")):
|
|
310
|
+
self.soundfont = self.fluidsynth.sfload(str(sf2))
|
|
311
|
+
self.fluidsynth.set_reverb(1, 1, 80, 1)
|
|
312
|
+
self.fluidsynth.start(driver=driver)
|
|
313
|
+
for channel in range(MAX_CHANNELS):
|
|
314
|
+
self.fluid_select(channel, 0, 0)
|
|
315
|
+
|
|
316
|
+
def fluid_select(self, channel: int=0, bank: int=0, preset: int=0) -> None:
|
|
317
|
+
if self.fluidsynth:
|
|
318
|
+
self.fluidsynth.program_select(channel, self.soundfont, bank, preset)
|
|
319
|
+
|
|
320
|
+
def fluid_key_down(self, note: int, velocity: int=127, channel: int=0) -> None:
|
|
321
|
+
if self.fluidsynth:
|
|
322
|
+
self.fluidsynth.noteon(channel, note, velocity)
|
|
323
|
+
|
|
324
|
+
def fluid_key_up(self, note: int, channel: int=0) -> None:
|
|
325
|
+
if self.fluidsynth:
|
|
326
|
+
self.fluidsynth.noteoff(channel, note)
|
|
327
|
+
|
|
328
|
+
def fluid_all_notes_off(self) -> None:
|
|
329
|
+
if self.fluidsynth:
|
|
330
|
+
for channel, note in itertools.product(range(MAX_CHANNELS), range(MAX_NOTE)):
|
|
331
|
+
self.fluidsynth.noteoff(channel, note)
|
|
332
|
+
|
|
333
|
+
def fluid_render(self,
|
|
334
|
+
midi: Path,
|
|
335
|
+
soundfont: Path=None,
|
|
336
|
+
output: Path=None
|
|
337
|
+
) -> Path:
|
|
338
|
+
if not self.fluidsynth:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
# Get temporary cached file
|
|
342
|
+
if output is None:
|
|
343
|
+
midi_hash = hashlib.md5(BrokenPath(midi).read_bytes()).hexdigest()
|
|
344
|
+
output = Path(tempfile.gettempdir())/f"ShaderFlow-Midi2Audio-{midi_hash}.wav"
|
|
345
|
+
|
|
346
|
+
import midi2audio
|
|
347
|
+
with BrokenSpinner(log.info(f"Rendering FluidSynth Midi ({midi}) → ({output})")):
|
|
348
|
+
midi2audio.FluidSynth(soundfont).midi_to_audio(midi, output)
|
|
349
|
+
|
|
350
|
+
# Normalize audio with FFmpeg
|
|
351
|
+
normalized = output.with_suffix(".aac")
|
|
352
|
+
with BrokenSpinner(log.info(f"Normalizing Audio ({output}) → ({normalized})")):
|
|
353
|
+
(BrokenFFmpeg()
|
|
354
|
+
.quiet()
|
|
355
|
+
.overwrite()
|
|
356
|
+
.input(output)
|
|
357
|
+
.filter("loudnorm")
|
|
358
|
+
.aac()
|
|
359
|
+
.output(normalized)
|
|
360
|
+
).run()
|
|
361
|
+
|
|
362
|
+
return BrokenPath(normalized)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from math import pi
|
|
3
|
+
from typing import Callable, Iterable, Tuple, Union
|
|
4
|
+
|
|
5
|
+
import cachetools
|
|
6
|
+
import numpy
|
|
7
|
+
import scipy
|
|
8
|
+
from attr import Factory, define, field
|
|
9
|
+
|
|
10
|
+
from Broken import SameTracker, log
|
|
11
|
+
from Broken.Types import Hertz, Samples, Seconds
|
|
12
|
+
from ShaderFlow.Common.Notes import BrokenPianoNote
|
|
13
|
+
from ShaderFlow.Module import ShaderModule
|
|
14
|
+
from ShaderFlow.Modules.Audio import BrokenAudio
|
|
15
|
+
from ShaderFlow.Modules.Dynamics import DynamicNumber
|
|
16
|
+
from ShaderFlow.Texture import ShaderTexture, TextureType
|
|
17
|
+
from ShaderFlow.Variable import ShaderVariable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BrokenAudioFourierMagnitude:
|
|
21
|
+
"""Given an raw FFT, interpret the complex number as some size"""
|
|
22
|
+
def Amplitude(x: numpy.ndarray) -> numpy.ndarray:
|
|
23
|
+
return numpy.abs(x)
|
|
24
|
+
|
|
25
|
+
def Power(x: numpy.ndarray) -> numpy.ndarray:
|
|
26
|
+
return x*x.conjugate()
|
|
27
|
+
|
|
28
|
+
class BrokenAudioFourierVolume:
|
|
29
|
+
"""Convert the FFT into the final spectrogram's magnitude bin"""
|
|
30
|
+
|
|
31
|
+
def dBFS(x: numpy.ndarray) -> numpy.ndarray:
|
|
32
|
+
return 10*numpy.log10(x)
|
|
33
|
+
|
|
34
|
+
def Sqrt(x: numpy.ndarray) -> numpy.ndarray:
|
|
35
|
+
return numpy.sqrt(x)
|
|
36
|
+
|
|
37
|
+
def Linear(x: numpy.ndarray) -> numpy.ndarray:
|
|
38
|
+
return x
|
|
39
|
+
|
|
40
|
+
def dBFsTremx(x: numpy.ndarray) -> numpy.ndarray:
|
|
41
|
+
return 10*(numpy.log10(x+0.1) + 1)/1.0414
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BrokenAudioSpectrogramInterpolation:
|
|
45
|
+
"""Interpolate the FFT values, discrete to continuous"""
|
|
46
|
+
#
|
|
47
|
+
# I can explain this better later, but the idea is here:
|
|
48
|
+
# • https://www.desmos.com/calculator/vvixdoooty
|
|
49
|
+
# • https://en.wikipedia.org/wiki/Whittaker%E2%80%93Shannon_interpolation_formula
|
|
50
|
+
#
|
|
51
|
+
# Sinc(x) is already normalized (divided by the area, pi) as in sinc(x) = sin(pi*x)/(pi*x).
|
|
52
|
+
#
|
|
53
|
+
# The general case for a interpolation formula is to normalize some function f(x) by its area.
|
|
54
|
+
# For example, in the case of exp(-x^2) as the function, its area is the magical sqrt(pi)
|
|
55
|
+
# as seen in @3b1b https://www.youtube.com/watch?v=cy8r7WSuT1I
|
|
56
|
+
#
|
|
57
|
+
|
|
58
|
+
# Note: A value above 1.54 is recommended
|
|
59
|
+
def make_euler(end: float=1.54) -> Callable:
|
|
60
|
+
return (lambda x: numpy.exp(-(2*x/end)**2) / (end*(pi**0.5)))
|
|
61
|
+
|
|
62
|
+
def Dirac(x):
|
|
63
|
+
dirac = numpy.zeros(x.shape)
|
|
64
|
+
dirac[numpy.round(x) == 0] = 1
|
|
65
|
+
return dirac
|
|
66
|
+
|
|
67
|
+
Euler = make_euler(end=1.2)
|
|
68
|
+
|
|
69
|
+
def Sinc(x: numpy.ndarray) -> numpy.ndarray:
|
|
70
|
+
return numpy.abs(numpy.sinc(x))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BrokenAudioSpectrogramScale:
|
|
74
|
+
"""Functions that defines the y scale of the spectrogram. Tuples of f(x) and f^-1(x)"""
|
|
75
|
+
|
|
76
|
+
# Octave, matches the piano keys
|
|
77
|
+
# Todo: Make a generic base exponent?
|
|
78
|
+
Octave = (
|
|
79
|
+
lambda x: (numpy.log(x)/numpy.log(2)),
|
|
80
|
+
lambda x: (2**x)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Personally not a big fan
|
|
84
|
+
MEL = (
|
|
85
|
+
lambda x: 2595 * numpy.log10(1 + x/700),
|
|
86
|
+
lambda x: 700 * (10**(x/2595) - 1),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BrokenAudioSpectrogramWindow:
|
|
91
|
+
|
|
92
|
+
@functools.lru_cache
|
|
93
|
+
def hann_poisson_window(N: int, alpha: float=2) -> numpy.ndarray:
|
|
94
|
+
"""
|
|
95
|
+
Generate a Hann-Poisson window
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
N: The number of window samples
|
|
99
|
+
alpha: Slope of the exponential
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
numpy.array: Window samples
|
|
103
|
+
"""
|
|
104
|
+
n = numpy.arange(N)
|
|
105
|
+
hann = 0.5 * (1 - numpy.cos(2 * numpy.pi * n / N))
|
|
106
|
+
poisson = numpy.exp(-alpha * numpy.abs(N - 2*n) / N)
|
|
107
|
+
return hann * poisson
|
|
108
|
+
|
|
109
|
+
@functools.lru_cache
|
|
110
|
+
def hanning(size: int) -> numpy.ndarray:
|
|
111
|
+
"""Returns a hanning window of the given size"""
|
|
112
|
+
return numpy.hanning(size)
|
|
113
|
+
|
|
114
|
+
@functools.lru_cache
|
|
115
|
+
def none(size: int) -> numpy.ndarray:
|
|
116
|
+
"""Returns a none window of the given size"""
|
|
117
|
+
return numpy.ones(size)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@define(slots=False)
|
|
121
|
+
class BrokenSpectrogram:
|
|
122
|
+
audio: BrokenAudio = Factory(BrokenAudio)
|
|
123
|
+
|
|
124
|
+
fft_n: int = field(default=12, converter=int)
|
|
125
|
+
"""2^n FFT size, higher values, higher frequency resolution, less responsiveness"""
|
|
126
|
+
|
|
127
|
+
sample_rateio: int = field(default=1, converter=int)
|
|
128
|
+
"""Resample the input data by a factor, int for FFT optimizations"""
|
|
129
|
+
|
|
130
|
+
# Spectrogram properties
|
|
131
|
+
scale: Tuple[callable] = BrokenAudioSpectrogramScale.Octave
|
|
132
|
+
interpolation: callable = BrokenAudioSpectrogramInterpolation.Euler
|
|
133
|
+
magnitude_function: callable = BrokenAudioFourierMagnitude.Power
|
|
134
|
+
window_function: callable = BrokenAudioSpectrogramWindow.hanning
|
|
135
|
+
volume: callable = BrokenAudioFourierVolume.Sqrt
|
|
136
|
+
|
|
137
|
+
def __cache__(self) -> int:
|
|
138
|
+
return hash((
|
|
139
|
+
self.fft_n,
|
|
140
|
+
self.minimum_frequency,
|
|
141
|
+
self.maximum_frequency,
|
|
142
|
+
self.spectrogram_bins,
|
|
143
|
+
self.sample_rateio,
|
|
144
|
+
self.magnitude_function,
|
|
145
|
+
self.interpolation,
|
|
146
|
+
self.scale,
|
|
147
|
+
self.volume,
|
|
148
|
+
))
|
|
149
|
+
|
|
150
|
+
# # Fourier
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def fft_size(self) -> Samples:
|
|
154
|
+
return int(2**(self.fft_n) * self.sample_rateio)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def fft_bins(self) -> int:
|
|
158
|
+
return int(self.fft_size/2 + 1)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def fft_frequencies(self) -> Union[numpy.ndarray, Hertz]:
|
|
162
|
+
return numpy.fft.rfftfreq(self.fft_size, 1/(self.audio.samplerate*self.sample_rateio))
|
|
163
|
+
|
|
164
|
+
def fft(self) -> numpy.ndarray:
|
|
165
|
+
data = self.audio.get_last_n_samples(int(2**self.fft_n))
|
|
166
|
+
|
|
167
|
+
# Optionally resample the data
|
|
168
|
+
if self.sample_rateio != 1:
|
|
169
|
+
try:
|
|
170
|
+
import samplerate
|
|
171
|
+
except ModuleNotFoundError:
|
|
172
|
+
raise RuntimeError('\n'.join((
|
|
173
|
+
"Please install 'samplerate' optional dependency for resampling:"
|
|
174
|
+
"• Find it at: (https://pypi.org/project/samplerate)"
|
|
175
|
+
"• From Source (Rye): 'rye sync --features samplerate'"
|
|
176
|
+
"• From PIP: 'pip install broken-source[samplerate]'"
|
|
177
|
+
)))
|
|
178
|
+
data = numpy.array([samplerate.resample(x, self.sample_rateio, 'linear') for x in data])
|
|
179
|
+
|
|
180
|
+
return self.magnitude_function(
|
|
181
|
+
numpy.fft.rfft(self.window_function(self.fft_size) * data)
|
|
182
|
+
).astype(self.audio.dtype)
|
|
183
|
+
|
|
184
|
+
# # Spectrogram
|
|
185
|
+
|
|
186
|
+
def next(self) -> numpy.ndarray:
|
|
187
|
+
return self.spectrogram_matrix.dot(self.fft().T).T
|
|
188
|
+
return self.volume([
|
|
189
|
+
self.spectrogram_matrix @ channel
|
|
190
|
+
for channel in self.fft()
|
|
191
|
+
])
|
|
192
|
+
|
|
193
|
+
minimum_frequency: Hertz = 20.0
|
|
194
|
+
maximum_frequency: Hertz = 20000.0
|
|
195
|
+
spectrogram_bins: int = 1000
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def spectrogram_frequencies(self) -> numpy.ndarray:
|
|
199
|
+
return self.scale[1](numpy.linspace(
|
|
200
|
+
self.scale[0](self.minimum_frequency),
|
|
201
|
+
self.scale[0](self.maximum_frequency),
|
|
202
|
+
self.spectrogram_bins,
|
|
203
|
+
))
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
@cachetools.cached(cache={}, key=lambda self: self.__cache__())
|
|
207
|
+
def spectrogram_matrix(self) -> scipy.sparse.csr_matrix:
|
|
208
|
+
"""
|
|
209
|
+
Gets a transformation matrix that multiplied with self.fft yields "spectrogram bins" in custom scale
|
|
210
|
+
|
|
211
|
+
The idea to get the center frequencies on the custom scale is to compute the following:
|
|
212
|
+
$$ center_frequencies = T^-1(linspace(T(min), T(max), n)) $$
|
|
213
|
+
|
|
214
|
+
Where T(f) transforms a frequency to some scale (done in self.spectrogram_frequencies)
|
|
215
|
+
|
|
216
|
+
And then create many band-pass filters, each one centered on the center frequencies using
|
|
217
|
+
Whittaker-Shannon's interpolation formula per row of the matrix, considering the FFT bins as
|
|
218
|
+
a one-hertz-frequency function to interpolate, we find "the around frequencies" !
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
# Whittaker-Shannon interpolation formula per row of the matrix
|
|
222
|
+
matrix = numpy.array([
|
|
223
|
+
self.interpolation(theoretical_index - numpy.arange(self.fft_bins))
|
|
224
|
+
for theoretical_index in (self.spectrogram_frequencies/self.fft_frequencies[1])
|
|
225
|
+
], dtype=self.audio.dtype)
|
|
226
|
+
|
|
227
|
+
# Zero out near-zero values
|
|
228
|
+
matrix[numpy.abs(matrix) < 1e-5] = 0
|
|
229
|
+
|
|
230
|
+
# Create a scipy sparse for much faster matrix multiplication
|
|
231
|
+
return scipy.sparse.csr_matrix(matrix)
|
|
232
|
+
|
|
233
|
+
def from_notes(self,
|
|
234
|
+
start: BrokenPianoNote,
|
|
235
|
+
end: BrokenPianoNote,
|
|
236
|
+
bins: int=1000,
|
|
237
|
+
piano: bool=False,
|
|
238
|
+
tuning: Hertz=440,
|
|
239
|
+
):
|
|
240
|
+
start = BrokenPianoNote.get(start, tuning=tuning)
|
|
241
|
+
end = BrokenPianoNote.get(end, tuning=tuning)
|
|
242
|
+
log.info(f"Making Spectrogram Piano Matrix from notes ({start.name} - {end.name})")
|
|
243
|
+
self.minimum_frequency = start.frequency
|
|
244
|
+
self.maximum_frequency = end.frequency
|
|
245
|
+
if not piano:
|
|
246
|
+
self.spectrogram_bins = bins
|
|
247
|
+
else:
|
|
248
|
+
# The advertised number of bins should start and end on a note
|
|
249
|
+
half_semitone = 2**(0.5/12)
|
|
250
|
+
self.spectrogram_bins = ((end.note - start.note) + 1)
|
|
251
|
+
self.minimum_frequency /= half_semitone
|
|
252
|
+
self.maximum_frequency *= half_semitone
|
|
253
|
+
|
|
254
|
+
# -------------------------------------------------------------------------------------------------|
|
|
255
|
+
|
|
256
|
+
@define
|
|
257
|
+
class ShaderSpectrogram(BrokenSpectrogram, ShaderModule):
|
|
258
|
+
name: str = "iSpectrogram"
|
|
259
|
+
"""Prefix name and Texture name of the Shader Variables"""
|
|
260
|
+
|
|
261
|
+
length: Seconds = 5
|
|
262
|
+
"""Horizontal length of the Spectrogram content"""
|
|
263
|
+
|
|
264
|
+
offset: Samples = 0
|
|
265
|
+
"""Modulus of total samples written by length, used for scrolling mode"""
|
|
266
|
+
|
|
267
|
+
smooth: bool = False
|
|
268
|
+
"""Enables Linear interpolation on the Texture, not useful for Bars mode"""
|
|
269
|
+
|
|
270
|
+
scrolling: bool = False
|
|
271
|
+
""""""
|
|
272
|
+
|
|
273
|
+
dynamics: DynamicNumber = None
|
|
274
|
+
"""Apply Dynamics to the FFT data"""
|
|
275
|
+
|
|
276
|
+
texture: ShaderTexture = None
|
|
277
|
+
"""Internal managed Texture"""
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def length_samples(self) -> Samples:
|
|
281
|
+
return int(max(1, self.length*self.scene.fps))
|
|
282
|
+
|
|
283
|
+
def __post__(self):
|
|
284
|
+
self.dynamics = DynamicNumber(frequency=4, zeta=1, response=0)
|
|
285
|
+
self.texture = ShaderTexture(
|
|
286
|
+
scene=self.scene,
|
|
287
|
+
name=self.name,
|
|
288
|
+
dtype=TextureType.f4,
|
|
289
|
+
repeat_y=False,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
__same__: SameTracker = Factory(SameTracker)
|
|
293
|
+
|
|
294
|
+
def update(self):
|
|
295
|
+
self.texture.components = self.audio.channels
|
|
296
|
+
self.texture.filter = ("linear" if self.smooth else "nearest")
|
|
297
|
+
self.texture.height = self.spectrogram_bins
|
|
298
|
+
self.texture.width = self.length_samples
|
|
299
|
+
self.offset = (self.offset + 1) % self.length_samples
|
|
300
|
+
if not self.__same__(self.audio.tell):
|
|
301
|
+
self.dynamics.target = self.next().T.reshape(2, -1)
|
|
302
|
+
self.dynamics.next(dt=abs(self.scene.dt))
|
|
303
|
+
self.texture.write(
|
|
304
|
+
viewport=(self.offset, 0, 1, self.spectrogram_bins),
|
|
305
|
+
data=self.dynamics.value,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def pipeline(self) -> Iterable[ShaderVariable]:
|
|
309
|
+
yield ShaderVariable("uniform", "int", f"{self.name}Length", self.length_samples)
|
|
310
|
+
yield ShaderVariable("uniform", "int", f"{self.name}Bins", self.spectrogram_bins)
|
|
311
|
+
yield ShaderVariable("uniform", "float", f"{self.name}Offset", self.offset/self.length_samples)
|
|
312
|
+
yield ShaderVariable("uniform", "int", f"{self.name}Smooth", self.smooth)
|
|
313
|
+
yield ShaderVariable("uniform", "float", f"{self.name}Min", self.spectrogram_frequencies[0])
|
|
314
|
+
yield ShaderVariable("uniform", "float", f"{self.name}Max", self.spectrogram_frequencies[-1])
|
|
315
|
+
yield ShaderVariable("uniform", "bool", f"{self.name}Scroll", self.scrolling)
|