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.

Files changed (51) hide show
  1. ShaderFlow/Common/Imgui.py +0 -0
  2. ShaderFlow/Common/Notes.py +134 -0
  3. ShaderFlow/Common/__init__.py +0 -0
  4. ShaderFlow/Message.py +147 -0
  5. ShaderFlow/Module.py +162 -0
  6. ShaderFlow/Modules/Audio.py +431 -0
  7. ShaderFlow/Modules/Bouncing.py +94 -0
  8. ShaderFlow/Modules/Camera.py +451 -0
  9. ShaderFlow/Modules/Depth.py +0 -0
  10. ShaderFlow/Modules/Dynamics.py +288 -0
  11. ShaderFlow/Modules/Frametimer.py +79 -0
  12. ShaderFlow/Modules/Keyboard.py +43 -0
  13. ShaderFlow/Modules/Noise.py +80 -0
  14. ShaderFlow/Modules/OpticalFlow.py +1 -0
  15. ShaderFlow/Modules/Piano.py +362 -0
  16. ShaderFlow/Modules/Spectrogram.py +315 -0
  17. ShaderFlow/Modules/Video.py +234 -0
  18. ShaderFlow/Modules/Waveform.py +100 -0
  19. ShaderFlow/Modules/__init__.py +0 -0
  20. ShaderFlow/Resources/Images/ShaderFlow.png +0 -0
  21. ShaderFlow/Resources/Images/ShaderFlow.svg +1 -0
  22. ShaderFlow/Resources/Scenes/Examples/Demo.py +284 -0
  23. ShaderFlow/Resources/Scenes/Examples/GLSL/Bars.frag +20 -0
  24. ShaderFlow/Resources/Scenes/Examples/GLSL/Bouncing.frag +50 -0
  25. ShaderFlow/Resources/Scenes/Examples/GLSL/Life/Simulation.glsl +49 -0
  26. ShaderFlow/Resources/Scenes/Examples/GLSL/Life/Visuals.glsl +31 -0
  27. ShaderFlow/Resources/Scenes/Examples/GLSL/MotionBlur.frag +20 -0
  28. ShaderFlow/Resources/Scenes/Examples/GLSL/Multipass.frag +43 -0
  29. ShaderFlow/Resources/Scenes/Examples/GLSL/RayMarch.frag +59 -0
  30. ShaderFlow/Resources/Scenes/Examples/GLSL/ShaderToy.frag +65 -0
  31. ShaderFlow/Resources/Scenes/Examples/GLSL/Video.frag +5 -0
  32. ShaderFlow/Resources/Scenes/Examples/GLSL/Visualizer.frag +75 -0
  33. ShaderFlow/Resources/Scenes/Examples/GLSL/Waveform.frag +18 -0
  34. ShaderFlow/Resources/Shaders/Fragment/Default.glsl +65 -0
  35. ShaderFlow/Resources/Shaders/Fragment/Final.glsl +7 -0
  36. ShaderFlow/Resources/Shaders/Fragment/Loading.frag +40 -0
  37. ShaderFlow/Resources/Shaders/Fragment/Missing.glsl +23 -0
  38. ShaderFlow/Resources/Shaders/Include/Camera.glsl +166 -0
  39. ShaderFlow/Resources/Shaders/Include/Complex.glsl +0 -0
  40. ShaderFlow/Resources/Shaders/Include/ShaderFlow.glsl +444 -0
  41. ShaderFlow/Resources/Shaders/Vertex/Default.glsl +21 -0
  42. ShaderFlow/Scene.py +1124 -0
  43. ShaderFlow/Shader.py +391 -0
  44. ShaderFlow/Texture.py +528 -0
  45. ShaderFlow/Variable.py +128 -0
  46. ShaderFlow/__init__.py +11 -0
  47. ShaderFlow/__main__.py +22 -0
  48. shaderflow-0.4.0.dev1.dist-info/METADATA +79 -0
  49. shaderflow-0.4.0.dev1.dist-info/RECORD +51 -0
  50. shaderflow-0.4.0.dev1.dist-info/WHEEL +5 -0
  51. 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)