CreativePython 0.0.0__py3-none-any.whl → 0.0.2__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.
- AudioSample.py +969 -0
- _RealtimeAudioPlayer.py +757 -0
- creativepython-0.0.2.dist-info/METADATA +65 -0
- creativepython-0.0.2.dist-info/RECORD +16 -0
- {creativepython-0.0.0.dist-info → creativepython-0.0.2.dist-info}/WHEEL +1 -1
- creativepython-0.0.2.dist-info/entry_points.txt +3 -0
- creativepython-0.0.2.dist-info/top_level.txt +10 -0
- creativepython_setup.py +101 -0
- gui.py +3562 -0
- image.py +76 -0
- midi.py +942 -0
- music.py +4513 -0
- osc.py +385 -0
- timer.py +574 -0
- CreativePython/import_test.py +0 -6
- creativepython-0.0.0.dist-info/METADATA +0 -18
- creativepython-0.0.0.dist-info/RECORD +0 -7
- creativepython-0.0.0.dist-info/top_level.txt +0 -1
- /CreativePython/__init__.py → /__init__.py +0 -0
- {creativepython-0.0.0.dist-info → creativepython-0.0.2.dist-info/licenses}/LICENSE +0 -0
AudioSample.py
ADDED
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
#######################################################################################
|
|
2
|
+
# AudioSample.py Version 1.0 19-May-2025
|
|
3
|
+
# Trevor Ritchie, Taj Ballinger, and Bill Manaris
|
|
4
|
+
#
|
|
5
|
+
#######################################################################################
|
|
6
|
+
#
|
|
7
|
+
# [LICENSING GOES HERE]
|
|
8
|
+
#
|
|
9
|
+
#######################################################################################
|
|
10
|
+
#
|
|
11
|
+
#
|
|
12
|
+
#######################################################################################
|
|
13
|
+
|
|
14
|
+
import os # for checking file existence and path operations
|
|
15
|
+
import math # for logarithmic calculations in pitch/frequency conversions
|
|
16
|
+
import atexit # For cleanup on exit
|
|
17
|
+
|
|
18
|
+
from _RealtimeAudioPlayer import _RealtimeAudioPlayer
|
|
19
|
+
from timer import *
|
|
20
|
+
|
|
21
|
+
### helper functions ##############################################################
|
|
22
|
+
|
|
23
|
+
def noteToFreq(pitch):
|
|
24
|
+
"""
|
|
25
|
+
Converts a MIDI pitch to the corresponding frequency. A4 corresponds to the note number 69
|
|
26
|
+
(concert pitch is set to 440Hz by default).
|
|
27
|
+
"""
|
|
28
|
+
concertPitch = 440.0 # 440Hz
|
|
29
|
+
|
|
30
|
+
frequency = concertPitch * 2 ** ( (pitch - 69) / 12.0 )
|
|
31
|
+
|
|
32
|
+
return frequency
|
|
33
|
+
|
|
34
|
+
def freqToNote(hz):
|
|
35
|
+
"""Converts frequency in Hz to MIDI pitch (float for microtonal accuracy)."""
|
|
36
|
+
if hz <= 0: return 0.0 # MIDI pitch 0 often means silence or unpitched
|
|
37
|
+
return 69.0 + 12.0 * math.log2(hz / 440.0)
|
|
38
|
+
|
|
39
|
+
# default midi pitch for AudioSample objects
|
|
40
|
+
A4 = 69
|
|
41
|
+
|
|
42
|
+
# keep track of active AudioSample instances for cleanup
|
|
43
|
+
_activeAudioSamples = []
|
|
44
|
+
|
|
45
|
+
### AudioSample Class ##############################################################
|
|
46
|
+
|
|
47
|
+
class AudioSample:
|
|
48
|
+
"""
|
|
49
|
+
Encapsulates a sound object created from an external audio file, which can be played once,
|
|
50
|
+
looped, paused, resumed, and stopped. Also, each sound has an actual pitch or frequency, namely
|
|
51
|
+
the actual pitch, or fundamental frequency of the recorded sound (default is A4 - 69 or 440.0),
|
|
52
|
+
so we can play other note pitches or frequencies with it (through pitch shifting).
|
|
53
|
+
Also, the sound object allows for polyphony - the default is 16 different voices, which can be played,
|
|
54
|
+
pitch-shifted, looped, etc. indepedently from each other. This way, we can play chords, etc., which is very nice.
|
|
55
|
+
Finally, we can set/get its volume (0-127), panning (0-127), pitch (0-127), and frequency (in Hz).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, filename, actualPitch=A4, volume=127, voices=16):
|
|
59
|
+
"""Initialize audio sample with the given file and properties."""
|
|
60
|
+
if not os.path.isfile(filename):
|
|
61
|
+
raise FileNotFoundError(f"Audio file '{filename}' not found.")
|
|
62
|
+
|
|
63
|
+
self.filename = filename
|
|
64
|
+
self.maxVoices = voices
|
|
65
|
+
|
|
66
|
+
# resolve actualPitch to actualPitch (MIDI) and actualFrequency (Hz)
|
|
67
|
+
if isinstance(actualPitch, int) and 0 <= actualPitch <= 127:
|
|
68
|
+
self.actualPitch = float(actualPitch)
|
|
69
|
+
self.actualFrequency = noteToFreq(self.actualPitch)
|
|
70
|
+
elif isinstance(actualPitch, float):
|
|
71
|
+
self.actualFrequency = actualPitch
|
|
72
|
+
self.actualPitch = freqToNote(actualPitch)
|
|
73
|
+
if not (0 <= self.actualPitch <= 127): # check if resulting pitch is valid
|
|
74
|
+
print(f"Warning: Frequency {actualPitch}Hz results in MIDI pitch {self.actualPitch}, which is outside the 0-127 range. Clamping to nearest valid.")
|
|
75
|
+
self.actualPitch = max(0.0, min(127.0, self.actualPitch))
|
|
76
|
+
self.actualFrequency = noteToFreq(self.actualPitch) # recalculate frequency from clamped pitch
|
|
77
|
+
else:
|
|
78
|
+
self.actualPitch = float(A4)
|
|
79
|
+
self.actualFrequency = noteToFreq(self.actualPitch)
|
|
80
|
+
raise TypeError(f"actualPitch ({actualPitch}) must be an int (0-127) or float (Hz). Defaulting to A4.")
|
|
81
|
+
|
|
82
|
+
# validate volume (0-127)
|
|
83
|
+
if not (isinstance(volume, int) and 0 <= volume <= 127):
|
|
84
|
+
print(f"Warning: Volume ({volume}) is invalid. Must be an integer between 0 and 127. Defaulting to 127.")
|
|
85
|
+
self.initialVolume = 127
|
|
86
|
+
else:
|
|
87
|
+
self.initialVolume = volume
|
|
88
|
+
|
|
89
|
+
self._players = []
|
|
90
|
+
self._currentPitches = []
|
|
91
|
+
self._currentFrequencies = []
|
|
92
|
+
self._currentVolumes = []
|
|
93
|
+
self._currentPannings = []
|
|
94
|
+
self._isPausedFlags = []
|
|
95
|
+
self._currentLoopSettings = []
|
|
96
|
+
|
|
97
|
+
for i in range(self.maxVoices):
|
|
98
|
+
try:
|
|
99
|
+
# _RealtimeAudioPlayer's actualPitch should be the base pitch of the sound file
|
|
100
|
+
player = _RealtimeAudioPlayer(filepath=self.filename, actualPitch=self.actualPitch, loop=False)
|
|
101
|
+
self._players.append(player)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
# If a player fails to initialize, we might want to stop initializing this AudioSample
|
|
104
|
+
# or handle it more gracefully. For now, re-raise with context.
|
|
105
|
+
raise RuntimeError(f"Failed to initialize _RealtimeAudioPlayer for voice {i} with file '{self.filename}': {e}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# initialize current states for this voice
|
|
109
|
+
self._currentPitches.append(self.actualPitch)
|
|
110
|
+
self._currentFrequencies.append(self.actualFrequency)
|
|
111
|
+
self._currentVolumes.append(self.initialVolume)
|
|
112
|
+
self._currentPannings.append(64) # default API panning: 64 (center)
|
|
113
|
+
self._isPausedFlags.append(False)
|
|
114
|
+
self._currentLoopSettings.append({
|
|
115
|
+
'active': False,
|
|
116
|
+
'loopCountTarget': 0,
|
|
117
|
+
'loopRegionStartFrame': 0.0,
|
|
118
|
+
'loopRegionEndFrame': -1.0,
|
|
119
|
+
'loopsPerformedCurrent': 0,
|
|
120
|
+
'playDurationSourceFrames': -1.0
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
# Set initial parameters on the RealtimeAudioPlayer instance
|
|
124
|
+
# Volume: API (0-127) to Factor (0.0-1.0)
|
|
125
|
+
player.setVolumeFactor(self.initialVolume / 127.0)
|
|
126
|
+
|
|
127
|
+
# Panning: API (0-127, 64=center) to Factor (-1.0 to 1.0)
|
|
128
|
+
# (api_pan - 63.5) / 63.5
|
|
129
|
+
# For 64: (64 - 63.5) / 63.5 = 0.5 / 63.5 approx 0.0078 (effectively center)
|
|
130
|
+
# For 0 (left): (0 - 63.5) / 63.5 = -1.0
|
|
131
|
+
# For 127 (right): (127 - 63.5) / 63.5 = 63.5 / 63.5 = 1.0
|
|
132
|
+
apiPanValue = 64 # initial center panning
|
|
133
|
+
panFactor = (apiPanValue - 63.5) / 63.5
|
|
134
|
+
player.setPanFactor(panFactor)
|
|
135
|
+
|
|
136
|
+
# The player's pitch/frequency is already set via its actualPitch during init
|
|
137
|
+
# and corresponds to basePitch/baseFrequency. No need to call setPitch/setFrequency here
|
|
138
|
+
# unless we wanted it to start differently from its base.
|
|
139
|
+
|
|
140
|
+
# Initialize voice management attributes
|
|
141
|
+
self.freeVoices = list(range(self.maxVoices)) # holds list of free voices (numbered 0 to maxVoices-1)
|
|
142
|
+
self.voicesAllocatedToPitch = {} # a dictionary of voice lists - indexed by pitch (several voices per pitch is possible)
|
|
143
|
+
|
|
144
|
+
_activeAudioSamples.append(self)
|
|
145
|
+
|
|
146
|
+
def play(self, start=0, size=-1, voice=0):
|
|
147
|
+
"""
|
|
148
|
+
Play the corresponding sample once from the millisecond 'start' until the millisecond 'start'+'size'
|
|
149
|
+
(size == -1 means to the end). If 'start' and 'size' are omitted, play the complete sample.
|
|
150
|
+
If 'voice' is provided, the corresponding voice is used to play the sample (default is 0).
|
|
151
|
+
"""
|
|
152
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
153
|
+
print(f"AudioSample.play: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
|
|
154
|
+
voice = 0
|
|
155
|
+
|
|
156
|
+
if not (isinstance(start, (int, float)) and start >= 0):
|
|
157
|
+
print(f"AudioSample.play: Warning - Invalid start time {start}ms. Must be a non-negative number. Using 0ms.")
|
|
158
|
+
start = 0
|
|
159
|
+
|
|
160
|
+
player = self._players[voice]
|
|
161
|
+
start_seconds = start / 1000.0
|
|
162
|
+
startAtBeginning = True
|
|
163
|
+
|
|
164
|
+
if start > 0:
|
|
165
|
+
player.setCurrentTime(start_seconds) # Sets player.playbackPosition
|
|
166
|
+
startAtBeginning = False
|
|
167
|
+
|
|
168
|
+
calculated_play_duration_source_frames = -1.0
|
|
169
|
+
loop_region_start_f = 0.0
|
|
170
|
+
if player.getFrameRate() > 0:
|
|
171
|
+
loop_region_start_f = (start / 1000.0) * player.getFrameRate() if start > 0 else 0.0
|
|
172
|
+
|
|
173
|
+
if size > 0: # size is in ms
|
|
174
|
+
size_seconds = size / 1000.0
|
|
175
|
+
frameRate = player.getFrameRate()
|
|
176
|
+
if frameRate > 0:
|
|
177
|
+
calculated_play_duration_source_frames = size_seconds * frameRate
|
|
178
|
+
else:
|
|
179
|
+
print(f"AudioSample.play: Warning - Could not determine valid frame rate for voice {voice}. 'size' parameter will be ignored.")
|
|
180
|
+
elif size == 0:
|
|
181
|
+
calculated_play_duration_source_frames = 0.0
|
|
182
|
+
|
|
183
|
+
# Store non-looping settings (or settings for a single iteration if size is given)
|
|
184
|
+
self._currentLoopSettings[voice] = {
|
|
185
|
+
'active': False, # signifies it's a play-once (or play-duration)
|
|
186
|
+
'loopCountTarget': 0, # ensures it's treated as non-looping by RTA if loop=False
|
|
187
|
+
'loopRegionStartFrame': loop_region_start_f,
|
|
188
|
+
'loopRegionEndFrame': -1.0, # not critical for non-looping, RTA uses targetEndSourceFrame based on duration
|
|
189
|
+
'loopsPerformedCurrent': 0,
|
|
190
|
+
'playDurationSourceFrames': calculated_play_duration_source_frames # store this for resume
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# ensure the player is set to not loop for this single play command.
|
|
194
|
+
player.play(startAtBeginning=startAtBeginning,
|
|
195
|
+
loop=False,
|
|
196
|
+
playDurationSourceFrames=calculated_play_duration_source_frames,
|
|
197
|
+
loopRegionStartFrame=loop_region_start_f
|
|
198
|
+
# initialLoopsPerformed will be 0 (default) for a new play
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
self._isPausedFlags[voice] = False
|
|
202
|
+
|
|
203
|
+
def loop(self, times=-1, start=0, size=-1, voice=0):
|
|
204
|
+
"""
|
|
205
|
+
Repeat the corresponding sample indefinitely (times = -1), or the specified number of times
|
|
206
|
+
from millisecond 'start' until millisecond 'start'+'size' (size == -1 means to the end).
|
|
207
|
+
If 'start' and 'size' are omitted, repeat the complete sample.
|
|
208
|
+
If 'voice' is provided, the corresponding voice is used to loop the sample (default is 0).
|
|
209
|
+
"""
|
|
210
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
211
|
+
print(f"AudioSample.loop: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
|
|
212
|
+
voice = 0
|
|
213
|
+
|
|
214
|
+
if not (isinstance(start, (int, float)) and start >= 0):
|
|
215
|
+
print(f"AudioSample.loop: Warning - Invalid start time {start}ms. Must be a non-negative number. Using 0ms.")
|
|
216
|
+
start = 0
|
|
217
|
+
|
|
218
|
+
player = self._players[voice]
|
|
219
|
+
frameRate = player.getFrameRate()
|
|
220
|
+
|
|
221
|
+
loopRegionStartFrames = 0.0
|
|
222
|
+
if start > 0:
|
|
223
|
+
if frameRate > 0:
|
|
224
|
+
loopRegionStartFrames = (start / 1000.0) * frameRate
|
|
225
|
+
else:
|
|
226
|
+
print(f"AudioSample.loop: Warning - Invalid frame rate for voice {voice}. 'start' parameter might not work as expected.")
|
|
227
|
+
|
|
228
|
+
loopRegionEndFrames = -1.0 # Default to end of file
|
|
229
|
+
if size > 0: # size is in ms
|
|
230
|
+
if frameRate > 0:
|
|
231
|
+
startSeconds = start / 1000.0
|
|
232
|
+
sizeSeconds = size / 1000.0
|
|
233
|
+
loopRegionEndFrames = (startSeconds + sizeSeconds) * frameRate
|
|
234
|
+
# ensure end frame isn't before start frame due to rounding or tiny size
|
|
235
|
+
if loopRegionEndFrames <= loopRegionStartFrames:
|
|
236
|
+
print(f"AudioSample.loop: Warning - Loop 'size' ({size}ms) results in an end point before or at the start point. Will loop entire file from 'start'.")
|
|
237
|
+
loopRegionEndFrames = -1.0 # fallback to loop until end of file from start
|
|
238
|
+
else:
|
|
239
|
+
print(f"AudioSample.loop: Warning - Invalid frame rate for voice {voice}. 'size' parameter will be ignored, looping full file from 'start'.")
|
|
240
|
+
elif size == 0:
|
|
241
|
+
print("AudioSample.loop: Info - 'size=0' is not a valid duration for a loop segment. Looping entire file from 'start'.")
|
|
242
|
+
loopRegionEndFrames = -1.0 # loop entire file if size is 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# Determine if player needs to be reset to the beginning of its (new) loop segment.
|
|
246
|
+
# This is generally true unless we are trying to resume a multi-loop sequence, which is not the API here.
|
|
247
|
+
startAtBeginningOfLoopSegment = True
|
|
248
|
+
if start > 0: # If start is specified, we always want to set the current time.
|
|
249
|
+
player.setCurrentTime(start / 1000.0)
|
|
250
|
+
|
|
251
|
+
# The `times` parameter from AudioSample API:
|
|
252
|
+
# -1: loop indefinitely (maps to RealtimeAudioPlayer loopCountTarget = -1)
|
|
253
|
+
# 0: play once (maps to RealtimeAudioPlayer loopCountTarget = 0, and loop=False effectively)
|
|
254
|
+
# >0: loop N times (maps to RealtimeAudioPlayer loopCountTarget = N)
|
|
255
|
+
actualLoopCountTarget = times
|
|
256
|
+
playerShouldLoop = True
|
|
257
|
+
if times == 0:
|
|
258
|
+
playerShouldLoop = False
|
|
259
|
+
actualLoopCountTarget = 0 # Play once through the segment
|
|
260
|
+
# print("AudioSample.loop: Info - 'times=0' means play segment once without looping.")
|
|
261
|
+
|
|
262
|
+
# print(f"AudioSample.loop: Calling player.play with: loop={player_should_loop}, targetCount={actual_loop_count_target}, regionStartF={loop_region_start_frames}, regionEndF={loop_region_end_frames}")
|
|
263
|
+
|
|
264
|
+
# Store loop settings for this voice
|
|
265
|
+
self._currentLoopSettings[voice] = {
|
|
266
|
+
'active': playerShouldLoop,
|
|
267
|
+
'loopCountTarget': actualLoopCountTarget,
|
|
268
|
+
'loopRegionStartFrame': loopRegionStartFrames,
|
|
269
|
+
'loopRegionEndFrame': loopRegionEndFrames,
|
|
270
|
+
'loopsPerformedCurrent': 0, # Reset on new loop command
|
|
271
|
+
'playDurationSourceFrames': -1.0 # Not used for active looping
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
player.play(startAtBeginning=startAtBeginningOfLoopSegment, # True to make it start from loop_region_start_frames
|
|
275
|
+
loop=playerShouldLoop,
|
|
276
|
+
playDurationSourceFrames=-1.0, # Not used for looping
|
|
277
|
+
loopRegionStartFrame=loopRegionStartFrames,
|
|
278
|
+
loopRegionEndFrame=loopRegionEndFrames,
|
|
279
|
+
loopCountTarget=actualLoopCountTarget)
|
|
280
|
+
|
|
281
|
+
self._isPausedFlags[voice] = False
|
|
282
|
+
|
|
283
|
+
def stop(self, voice=0):
|
|
284
|
+
"""
|
|
285
|
+
Stops sample playback.
|
|
286
|
+
If optional 'voice' is provided, the corresponding voice is stopped (default is 0).
|
|
287
|
+
"""
|
|
288
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
289
|
+
print(f"AudioSample.stop: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
|
|
290
|
+
voice = 0
|
|
291
|
+
|
|
292
|
+
player = self._players[voice]
|
|
293
|
+
player.stop(immediate=True) # stop sample playback immediately
|
|
294
|
+
|
|
295
|
+
# reset loop settings for this voice to default non-looping
|
|
296
|
+
self._currentLoopSettings[voice] = {
|
|
297
|
+
'active': False,
|
|
298
|
+
'loopCountTarget': 0,
|
|
299
|
+
'loopRegionStartFrame': 0.0,
|
|
300
|
+
'loopRegionEndFrame': -1.0,
|
|
301
|
+
'loopsPerformedCurrent': 0,
|
|
302
|
+
'playDurationSourceFrames': -1.0
|
|
303
|
+
}
|
|
304
|
+
self._isPausedFlags[voice] = False # reset pause state on stop
|
|
305
|
+
|
|
306
|
+
def isPlaying(self, voice=0):
|
|
307
|
+
"""
|
|
308
|
+
Returns True if the sample is still playing, False otherwise.
|
|
309
|
+
If optional 'voice' is provided, the corresponding voice is checked
|
|
310
|
+
(default is 0).
|
|
311
|
+
"""
|
|
312
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
313
|
+
print(f"AudioSample.isPlaying: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning False.")
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
player = self._players[voice]
|
|
317
|
+
# player.isPlaying directly reflects if the RealtimeAudioPlayer is active.
|
|
318
|
+
# this will be False if stopped, paused (as pause calls player.stop(immediate=False)),
|
|
319
|
+
# or if playback naturally ended.
|
|
320
|
+
return player.isPlaying
|
|
321
|
+
|
|
322
|
+
def isPaused(self, voice=0):
|
|
323
|
+
"""
|
|
324
|
+
Returns True if the specified voice of the sample is currently paused.
|
|
325
|
+
"""
|
|
326
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
327
|
+
print(f"AudioSample.isPaused: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning False.")
|
|
328
|
+
return False
|
|
329
|
+
return self._isPausedFlags[voice]
|
|
330
|
+
|
|
331
|
+
def pause(self, voice=0):
|
|
332
|
+
"""
|
|
333
|
+
Pauses sample playback (remembers current position for resume).
|
|
334
|
+
If optional 'voice' is provided, the corresponding voice is paused
|
|
335
|
+
(default is 0).
|
|
336
|
+
"""
|
|
337
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
338
|
+
print(f"AudioSample.pause: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
|
|
339
|
+
voice = 0
|
|
340
|
+
|
|
341
|
+
player = self._players[voice]
|
|
342
|
+
# RealtimeAudioPlayer.stop(immediate=False) initiates a fade-out and stops the stream,
|
|
343
|
+
# preserving playbackPosition. This serves as our pause mechanism.
|
|
344
|
+
if player.isPlaying: # only pause if it's actually playing
|
|
345
|
+
# store current loops performed from the player *before* stopping it
|
|
346
|
+
current_loops_performed_by_player = player.getLoopsPerformed()
|
|
347
|
+
self._currentLoopSettings[voice]['loopsPerformedCurrent'] = current_loops_performed_by_player
|
|
348
|
+
|
|
349
|
+
player.stop(immediate=False)
|
|
350
|
+
self._isPausedFlags[voice] = True
|
|
351
|
+
else:
|
|
352
|
+
# if already stopped or paused, no action needed or print info
|
|
353
|
+
if self._isPausedFlags[voice]:
|
|
354
|
+
print(f"AudioSample.pause: Voice {voice} is already paused.")
|
|
355
|
+
else:
|
|
356
|
+
print(f"AudioSample.pause: Voice {voice} is not currently playing, cannot pause.")
|
|
357
|
+
|
|
358
|
+
def resume(self, voice=0):
|
|
359
|
+
"""
|
|
360
|
+
Resumes sample playback (from the paused position).
|
|
361
|
+
If optional 'voice' is provided, the corresponding voice is resumed
|
|
362
|
+
(default is 0).
|
|
363
|
+
"""
|
|
364
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
365
|
+
print(f"AudioSample.resume: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
|
|
366
|
+
voice = 0
|
|
367
|
+
|
|
368
|
+
player = self._players[voice]
|
|
369
|
+
loop_settings = self._currentLoopSettings[voice]
|
|
370
|
+
|
|
371
|
+
if self._isPausedFlags[voice]:
|
|
372
|
+
# this voice was explicitly paused by AudioSample.pause()
|
|
373
|
+
if player.isPlaying:
|
|
374
|
+
# This case should ideally not be hit if pause correctly stops the player.
|
|
375
|
+
# if it is hit, it means player is playing despite being paused from AudioSample's view.
|
|
376
|
+
print(f"AudioSample.resume: Voice {voice} was paused but player is already playing. Resuming anyway and clearing pause flag.")
|
|
377
|
+
# else:
|
|
378
|
+
# Expected path: paused and player is not playing, so resume.
|
|
379
|
+
# print(f"AudioSample.resume: Resuming explicitly paused voice {voice}.")
|
|
380
|
+
|
|
381
|
+
play_duration_for_resume = -1.0
|
|
382
|
+
if not loop_settings['active']: # if it was a single play being resumed
|
|
383
|
+
play_duration_for_resume = loop_settings.get('playDurationSourceFrames', -1.0)
|
|
384
|
+
|
|
385
|
+
player.play(
|
|
386
|
+
startAtBeginning=False, # resume from current player.playbackPosition
|
|
387
|
+
loop=loop_settings['active'],
|
|
388
|
+
playDurationSourceFrames=play_duration_for_resume,
|
|
389
|
+
loopRegionStartFrame=loop_settings['loopRegionStartFrame'],
|
|
390
|
+
loopRegionEndFrame=loop_settings['loopRegionEndFrame'],
|
|
391
|
+
loopCountTarget=loop_settings['loopCountTarget'],
|
|
392
|
+
initialLoopsPerformed=loop_settings['loopsPerformedCurrent'] # pass stored count
|
|
393
|
+
)
|
|
394
|
+
self._isPausedFlags[voice] = False
|
|
395
|
+
else:
|
|
396
|
+
# this voice was NOT explicitly paused by AudioSample.pause()
|
|
397
|
+
if player.isPlaying:
|
|
398
|
+
print(f"AudioSample.resume: Voice {voice} is already playing and was not paused!")
|
|
399
|
+
else:
|
|
400
|
+
print(f"AudioSample.resume: Voice {voice} was not paused via AudioSample.pause(). Call pause() first to use resume, or play() to start.")
|
|
401
|
+
# do not change _isPausedFlags[voice] here, it's already False.
|
|
402
|
+
# do not start playback.
|
|
403
|
+
|
|
404
|
+
def setFrequency(self, freq, voice=0):
|
|
405
|
+
"""
|
|
406
|
+
Sets the sample frequency (in Hz).
|
|
407
|
+
If optional 'voice' is provided, the frequency of the corresponding voice is set
|
|
408
|
+
(default is 0).
|
|
409
|
+
"""
|
|
410
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
411
|
+
print(f"AudioSample.setFrequency: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
|
|
412
|
+
voice = 0
|
|
413
|
+
|
|
414
|
+
if not (isinstance(freq, (int, float)) and freq > 0):
|
|
415
|
+
print(f"AudioSample.setFrequency: Warning - Invalid frequency value {freq}Hz. Must be a positive number. No change.")
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
player = self._players[voice]
|
|
419
|
+
player.setFrequency(float(freq)) # RealtimeAudioPlayer handles rate adjustment
|
|
420
|
+
|
|
421
|
+
self._currentFrequencies[voice] = float(freq)
|
|
422
|
+
# when frequency is set, pitch also changes in the player
|
|
423
|
+
self._currentPitches[voice] = player.getPitch()
|
|
424
|
+
|
|
425
|
+
def getFrequency(self, voice=0):
|
|
426
|
+
"""
|
|
427
|
+
Returns the current sample frequency.
|
|
428
|
+
If optional 'voice' is provided, the frequency of the corresponding voice is returned
|
|
429
|
+
(default is 0).
|
|
430
|
+
"""
|
|
431
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
432
|
+
print(f"AudioSample.getFrequency: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning frequency of voice 0.")
|
|
433
|
+
voice = 0
|
|
434
|
+
if not self._currentFrequencies: # should only happen if maxVoices was 0 or less.
|
|
435
|
+
print(f"AudioSample.getFrequency: Error - No voices available to get frequency from.")
|
|
436
|
+
return self.actualFrequency # fallback to baseFrequency of the sample itself
|
|
437
|
+
|
|
438
|
+
return self._currentFrequencies[voice]
|
|
439
|
+
|
|
440
|
+
def setPitch(self, pitch, voice=0):
|
|
441
|
+
"""
|
|
442
|
+
Sets the sample pitch (0-127) through pitch shifting from sample's base pitch.
|
|
443
|
+
If optional 'voice' is provided, the pitch of the corresponding voice is set
|
|
444
|
+
(default is 0).
|
|
445
|
+
"""
|
|
446
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
447
|
+
print(f"AudioSample.setPitch: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
|
|
448
|
+
voice = 0
|
|
449
|
+
|
|
450
|
+
if not (isinstance(pitch, (int, float)) and 0 <= pitch <= 127):
|
|
451
|
+
print(f"AudioSample.setPitch: Warning - Invalid pitch value {pitch}. Must be a number between 0 and 127. No change.")
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
player = self._players[voice]
|
|
455
|
+
player.setPitch(float(pitch)) # RealtimeAudioPlayer handles rate adjustment based on its basePitch
|
|
456
|
+
|
|
457
|
+
self._currentPitches[voice] = float(pitch)
|
|
458
|
+
# when pitch is set, frequency also changes in the player
|
|
459
|
+
self._currentFrequencies[voice] = player.getFrequency()
|
|
460
|
+
|
|
461
|
+
def getPitch(self, voice=0):
|
|
462
|
+
"""
|
|
463
|
+
Returns the sample's current pitch (it may be different from the default pitch).
|
|
464
|
+
If optional 'voice' is provided, the pitch of the corresponding voice is returned
|
|
465
|
+
(default is 0).
|
|
466
|
+
"""
|
|
467
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
468
|
+
print(f"AudioSample.getPitch: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning pitch of voice 0.")
|
|
469
|
+
voice = 0 # default to voice 0 if index is invalid
|
|
470
|
+
# If maxVoices is 0 (should not happen with proper init), this could still fail.
|
|
471
|
+
# However, __init__ should create at least one voice if voices >=1.
|
|
472
|
+
# If voices was 0, _currentPitches would be empty. This case should be rare.
|
|
473
|
+
if not self._currentPitches: # should only happen if maxVoices was 0 or less.
|
|
474
|
+
print(f"AudioSample.getPitch: Error - No voices available to get pitch from.")
|
|
475
|
+
return self.actualPitch # fallback to basePitch of the sample itself
|
|
476
|
+
|
|
477
|
+
return self._currentPitches[voice]
|
|
478
|
+
|
|
479
|
+
def getActualPitch(self):
|
|
480
|
+
"""
|
|
481
|
+
Return sample's actual pitch.
|
|
482
|
+
"""
|
|
483
|
+
return self.actualPitch
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def getActualFrequency(self):
|
|
487
|
+
"""
|
|
488
|
+
Return sample's actual frequency.
|
|
489
|
+
"""
|
|
490
|
+
return self.actualFrequency
|
|
491
|
+
|
|
492
|
+
def setPanning(self, panning, voice=0):
|
|
493
|
+
"""
|
|
494
|
+
Sets the panning of the sample (panning ranges from 0 – 127).
|
|
495
|
+
0 is full left, 64 is center, 127 is full right.
|
|
496
|
+
If optional 'voice' is provided, the panning of the corresponding voice is set
|
|
497
|
+
(default is 0).
|
|
498
|
+
"""
|
|
499
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
500
|
+
print(f"AudioSample.setPanning: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
|
|
501
|
+
voice = 0
|
|
502
|
+
|
|
503
|
+
if not (isinstance(panning, int) and 0 <= panning <= 127):
|
|
504
|
+
print(f"AudioSample.setPanning: Warning - Invalid panning value {panning}. Must be an integer between 0 and 127. No change.")
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
player = self._players[voice]
|
|
508
|
+
# convert API pan (0-127, 64=center) to player factor (-1.0 to 1.0)
|
|
509
|
+
# (api_pan - 63.5) / 63.5 ensures that 64 -> ~0.0, 0 -> -1.0, 127 -> 1.0
|
|
510
|
+
panFactor = (panning - 63.5) / 63.5
|
|
511
|
+
player.setPanFactor(panFactor)
|
|
512
|
+
|
|
513
|
+
self._currentPannings[voice] = panning
|
|
514
|
+
|
|
515
|
+
def getPanning(self, voice=0):
|
|
516
|
+
"""
|
|
517
|
+
Returns the current panning of the sample (panning ranges from 0 – 127).
|
|
518
|
+
If optional 'voice' is provided, the panning of the corresponding voice is returned
|
|
519
|
+
(default is 0).
|
|
520
|
+
"""
|
|
521
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
522
|
+
print(f"AudioSample.getPanning: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning panning of voice 0.")
|
|
523
|
+
voice = 0
|
|
524
|
+
if not self._currentPannings: # should only happen if maxVoices was 0 or less.
|
|
525
|
+
print(f"AudioSample.getPanning: Error - No voices available to get panning from.")
|
|
526
|
+
return 64 # default center panning
|
|
527
|
+
|
|
528
|
+
return self._currentPannings[voice]
|
|
529
|
+
|
|
530
|
+
def setVolume(self, volume, delay=2, voice=0):
|
|
531
|
+
"""
|
|
532
|
+
Sets the volume (amplitude) of the sample (volume ranges from 0 – 127).
|
|
533
|
+
Optional delay indicates speed with which to adjust volume (in milliseconds – default is 2).
|
|
534
|
+
If voice is provided, the volume of the corresponding voice is set (default is 0).
|
|
535
|
+
"""
|
|
536
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
537
|
+
print(f"AudioSample.setVolume: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
|
|
538
|
+
voice = 0
|
|
539
|
+
|
|
540
|
+
if not (isinstance(volume, int) and 0 <= volume <= 127):
|
|
541
|
+
print(f"AudioSample.setVolume: Warning - Invalid volume value {volume}. Must be an integer between 0 and 127. No change.")
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
if not (isinstance(delay, (int, float)) and delay >= 0):
|
|
545
|
+
print(f"AudioSample.setVolume: Warning - Invalid delay value {delay}ms. Must be non-negative. Using default behavior (near immediate change).")
|
|
546
|
+
delay = 2
|
|
547
|
+
|
|
548
|
+
player = self._players[voice]
|
|
549
|
+
targetVolumeFactor = volume / 127.0
|
|
550
|
+
|
|
551
|
+
# define a callback for the ramp to update the player's volume factor
|
|
552
|
+
def rampCallback(currentVolumeFactor):
|
|
553
|
+
player.setVolumeFactor(currentVolumeFactor)
|
|
554
|
+
# we update _currentVolumes with the target API volume, not the intermediate factors.
|
|
555
|
+
|
|
556
|
+
if delay > 0: # arbitrary threshold, e.g. > 5ms for a noticeable ramp
|
|
557
|
+
currentApiVolume = self._currentVolumes[voice]
|
|
558
|
+
currentVolumeFactor = currentApiVolume / 127.0
|
|
559
|
+
|
|
560
|
+
volumeRamp = LinearRamp(
|
|
561
|
+
delayMs=float(delay),
|
|
562
|
+
startValue=currentVolumeFactor,
|
|
563
|
+
endValue=targetVolumeFactor,
|
|
564
|
+
function=rampCallback,
|
|
565
|
+
stepMs=10 # default step for the ramp, can be adjusted
|
|
566
|
+
)
|
|
567
|
+
volumeRamp.start() # start volume ramp
|
|
568
|
+
# Note: _currentVolumes[voice] is updated at the end of the ramp by ramp_callback(endValue)
|
|
569
|
+
# or immediately if no ramp.
|
|
570
|
+
else:
|
|
571
|
+
# print(f"AudioSample.setVolume: Setting volume for voice {voice} to {volume} ({target_volume_factor:.2f}) immediately.")
|
|
572
|
+
player.setVolumeFactor(targetVolumeFactor)
|
|
573
|
+
|
|
574
|
+
self._currentVolumes[voice] = volume # store the target API volume
|
|
575
|
+
|
|
576
|
+
def getVolume(self, voice=0):
|
|
577
|
+
"""
|
|
578
|
+
Returns the current volume (amplitude) of the sample (volume ranges from 0 – 127).
|
|
579
|
+
If optional voice is provided, the volume of the corresponding voice is returned (default is 0).
|
|
580
|
+
"""
|
|
581
|
+
if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
|
|
582
|
+
print(f"AudioSample.getVolume: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning volume of voice 0.")
|
|
583
|
+
voice = 0
|
|
584
|
+
if not self._currentVolumes: # should only happen if maxVoices was 0 or less.
|
|
585
|
+
print(f"AudioSample.getVolume: Error - No voices available to get volume from.")
|
|
586
|
+
# In __init__, initialVolume is set. If maxVoices=0, _currentVolumes is empty.
|
|
587
|
+
# return initialVolume of the sample if possible, or a default like 127 or 0.
|
|
588
|
+
return self.initialVolume if hasattr(self, 'initialVolume') else 127
|
|
589
|
+
|
|
590
|
+
return self._currentVolumes[voice]
|
|
591
|
+
|
|
592
|
+
def allocateVoiceForPitch(self, pitch):
|
|
593
|
+
"""
|
|
594
|
+
Returns the next available free voice, and allocates it as associated with this pitch.
|
|
595
|
+
Returns None, if all voices / players are occupied.
|
|
596
|
+
"""
|
|
597
|
+
if (type(pitch) == int) and (0 <= pitch <= 127): # a MIDI pitch?
|
|
598
|
+
# yes, so convert pitch from MIDI number (int) to Hertz (float)
|
|
599
|
+
pitch = noteToFreq(pitch)
|
|
600
|
+
elif type(pitch) != float: # if pitch a frequency (a float, in Hz)?
|
|
601
|
+
raise TypeError("Pitch (" + str(pitch) + ") should be an int (range 0 and 127) or float (such as 440.0).")
|
|
602
|
+
|
|
603
|
+
# now, assume pitch contains a frequency (float)
|
|
604
|
+
|
|
605
|
+
# get next free voice (if any)
|
|
606
|
+
voiceForThisPitch = self.getNextFreeVoice()
|
|
607
|
+
|
|
608
|
+
if voiceForThisPitch != None: # if a free voice exists...
|
|
609
|
+
# associate it with this pitch
|
|
610
|
+
if pitch not in self.voicesAllocatedToPitch: # new pitch (not sounding already)?
|
|
611
|
+
self.voicesAllocatedToPitch[pitch] = [voiceForThisPitch] # remember that this voice is playing this pitch
|
|
612
|
+
else: # there is at least one other voice playing this pitch, so...
|
|
613
|
+
self.voicesAllocatedToPitch[pitch].append( voiceForThisPitch ) # append this voice (mimicking MIDI standard for polyphony of same pitches!!!)
|
|
614
|
+
|
|
615
|
+
# now, self.pitchSounding remembers that this voice is associated with this pitch
|
|
616
|
+
|
|
617
|
+
# now, return new voice for this pitch (it could be None, if no free voices exist!)
|
|
618
|
+
return voiceForThisPitch
|
|
619
|
+
|
|
620
|
+
def getNextFreeVoice(self):
|
|
621
|
+
"""
|
|
622
|
+
Returns the next available voice, i.e., a player that is not currently playing.
|
|
623
|
+
Returns None, if all voices / players are occupied.
|
|
624
|
+
"""
|
|
625
|
+
if len(self.freeVoices) > 0: # are there some free voices?
|
|
626
|
+
freeVoice = self.freeVoices.pop(0) # get the first available one
|
|
627
|
+
else: # all voices are being used
|
|
628
|
+
freeVoice = None
|
|
629
|
+
|
|
630
|
+
return freeVoice
|
|
631
|
+
|
|
632
|
+
def getVoiceForPitch(self, pitch):
|
|
633
|
+
"""
|
|
634
|
+
Returns the first voice (if any) associated with this pitch (there may be more than one - as we allow polyphony for the same pitch).
|
|
635
|
+
Returns None, if no voices are associated with this pitch.
|
|
636
|
+
"""
|
|
637
|
+
|
|
638
|
+
if (type(pitch) == int) and (0 <= pitch <= 127): # a MIDI pitch?
|
|
639
|
+
# yes, so convert pitch from MIDI number (int) to Hertz (float)
|
|
640
|
+
pitch = noteToFreq(pitch)
|
|
641
|
+
|
|
642
|
+
elif type(pitch) != float: # if pitch a frequency (a float, in Hz)
|
|
643
|
+
raise TypeError("Pitch (" + str(pitch) + ") should be an int (range 0 and 127) or float (such as 440.0).")
|
|
644
|
+
|
|
645
|
+
# now, assume pitch contains a frequency (float)
|
|
646
|
+
|
|
647
|
+
voice = None # initialize
|
|
648
|
+
|
|
649
|
+
if pitch in self.voicesAllocatedToPitch and len( self.voicesAllocatedToPitch[pitch] ) > 0: # does this pitch have voices allocated to it?
|
|
650
|
+
voice = self.voicesAllocatedToPitch[pitch][0] # first voice used for this pitch
|
|
651
|
+
else: # pitch is not currently sounding, so...
|
|
652
|
+
raise ValueError("Pitch (" + str(pitch) + ") is not currently playing!!!")
|
|
653
|
+
|
|
654
|
+
# now, let them know which voice was freed (if any)
|
|
655
|
+
return voice
|
|
656
|
+
|
|
657
|
+
def deallocateVoiceForPitch(self, pitch):
|
|
658
|
+
"""
|
|
659
|
+
Finds the first available voice (if any) associated with this pitch (there may be more than one - as we allow polyphony for the same pitch),
|
|
660
|
+
and puts it back in the pool of free voices - deallocates it.
|
|
661
|
+
"""
|
|
662
|
+
if (type(pitch) == int) and (0 <= pitch <= 127): # a MIDI pitch?
|
|
663
|
+
# yes, so convert pitch from MIDI number (int) to Hertz (float)
|
|
664
|
+
pitch = noteToFreq(pitch)
|
|
665
|
+
|
|
666
|
+
elif type(pitch) != float: # if pitch a frequency (a float, in Hz)
|
|
667
|
+
|
|
668
|
+
raise TypeError("Pitch (" + str(pitch) + ") should be an int (range 0 and 127) or float (such as 440.0).")
|
|
669
|
+
|
|
670
|
+
# now, assume pitch contains a frequency (float)
|
|
671
|
+
if pitch in self.voicesAllocatedToPitch and len( self.voicesAllocatedToPitch[pitch] ) > 0: # does this pitch have voices allocated to it?
|
|
672
|
+
freedVoice = self.voicesAllocatedToPitch[pitch].pop(0) # deallocate first voice used for this pitch
|
|
673
|
+
self.freeVoices.append( freedVoice ) # and return it back to the pool of free voices
|
|
674
|
+
|
|
675
|
+
else: # pitch is not currently sounding, so...
|
|
676
|
+
raise ValueError("Pitch (" + str(pitch) + ") is not currently playing!!!")
|
|
677
|
+
|
|
678
|
+
# done!!!
|
|
679
|
+
|
|
680
|
+
def getFrameRate(self):
|
|
681
|
+
"""
|
|
682
|
+
Returns the sample's recording rate (e.g., 44100.0 Hz).
|
|
683
|
+
"""
|
|
684
|
+
if not self._players:
|
|
685
|
+
print("AudioSample.getFrameRate: Warning - No audio players initialized for this sample.")
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
# all players share the same frame rate as they are from the same file
|
|
689
|
+
return self._players[0].getFrameRate()
|
|
690
|
+
|
|
691
|
+
def __del__(self):
|
|
692
|
+
"""
|
|
693
|
+
Destructor for AudioSample. Cleans up the sample's player(s).
|
|
694
|
+
"""
|
|
695
|
+
# ensure close is idempotent and handles being called multiple times
|
|
696
|
+
if not hasattr(self, '_players') or not self._players: # check if already closed or not initialized
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
for i, player in enumerate(self._players):
|
|
700
|
+
if player: # check if player instance exists
|
|
701
|
+
try:
|
|
702
|
+
player.close()
|
|
703
|
+
except Exception as e:
|
|
704
|
+
print(f"AudioSample.close: Error closing player {i} for '{self.filename}': {e}")
|
|
705
|
+
self._players = [] # clear the list to help with garbage collection and prevent reuse
|
|
706
|
+
|
|
707
|
+
# remove from global list if present
|
|
708
|
+
if self in _activeAudioSamples:
|
|
709
|
+
try:
|
|
710
|
+
_activeAudioSamples.remove(self)
|
|
711
|
+
except ValueError:
|
|
712
|
+
# this can happen if close() is called multiple times.
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class Envelope():
|
|
717
|
+
""" This class knows how to adjust the volume of an Audio Sample over time, in order to help shape its sound.
|
|
718
|
+
|
|
719
|
+
It consists of:
|
|
720
|
+
|
|
721
|
+
- a list of attack times (in milliseconds, relative from the previous time),
|
|
722
|
+
- a list of volumes - to be reached at the correspondng attack times (parallel lists),
|
|
723
|
+
- the delay time (in milliseconds - relative from the last attack time), of how long to wait to get to the sustain value (see next),
|
|
724
|
+
- the sustain value (volume to maintain while sustaining), and
|
|
725
|
+
- the release time (in milliseconds, relative from the END of the sound) - how long the fade-out is, i.e., to reach a volume of zero.
|
|
726
|
+
|
|
727
|
+
NOTE: Notice how all time values are relative to the previous one, with the exception of
|
|
728
|
+
|
|
729
|
+
- the first attack value, which is relative the start of the sound, and
|
|
730
|
+
- the release time, which is relative to (goes beyond) the end of the sound.
|
|
731
|
+
|
|
732
|
+
This last one is VERY important - i.e., release time goes past the end of the sound!!!
|
|
733
|
+
"""
|
|
734
|
+
|
|
735
|
+
def __init__(self, attackTimes = [2, 20], attackVolumes = [0.5, 0.8], delayTime = 20, sustainVolume = 1.0, releaseTime = 150):
|
|
736
|
+
"""
|
|
737
|
+
attack times - in milliseconds, first one is from start of sound, all others are relative to the previous one
|
|
738
|
+
attack volumes - range from 0.0 (silence) to 1.0 (max), parallel to attack times - volumes to reach at corresponding times
|
|
739
|
+
delay time - in milliseconds, relative from the last attack time - how long to wait to reach to sustain volume (see next)
|
|
740
|
+
sustain volume - 0.0 to 1.0, volume to maintain while playing the main body of the sound
|
|
741
|
+
release time - in milliseconds, relative to the END of the sound - how long to fade out (after end of sound).
|
|
742
|
+
"""
|
|
743
|
+
|
|
744
|
+
self.attackTimes = None # in milliseconds, relative from previous time
|
|
745
|
+
self.attackVolumes = None # and the corresponding volumes
|
|
746
|
+
self.delayTime = None # in milliseconds, relative from previous time
|
|
747
|
+
self.sustainVolume = None # to reach this volume
|
|
748
|
+
self.releaseTime = None # in milliseconds, length of fade out - beyond END of sound
|
|
749
|
+
|
|
750
|
+
# udpate above values (this will do appropriate error checks, so that we do not repeat that code twice here)
|
|
751
|
+
self.setAttackTimesAndVolumes(attackTimes, attackVolumes)
|
|
752
|
+
self.setDelayTime(delayTime)
|
|
753
|
+
self.setSustainVolume(sustainVolume)
|
|
754
|
+
self.setReleaseTime(releaseTime)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def setAttackTimesAndVolumes(self, attackTimes, attackVolumes):
|
|
758
|
+
""" Sets attack times and volumes. Attack times are in milliseconds, relative from previous time (first one is from start of sound).
|
|
759
|
+
Attack volumes are between 0.0 and 1.0, and are the corresponding volumes to be set at the given times.
|
|
760
|
+
|
|
761
|
+
NOTE: We do not provide individual functions to set attack times and to set volumes,
|
|
762
|
+
as it is very hard to check for parallelism (same length constraint) - a chicken-and-egg problem...
|
|
763
|
+
"""
|
|
764
|
+
|
|
765
|
+
# make sure attack times and volumes are parallel
|
|
766
|
+
if len(attackTimes) != len(attackVolumes):
|
|
767
|
+
|
|
768
|
+
raise IndexError("Attack times and volumes must have the same length.")
|
|
769
|
+
|
|
770
|
+
# make sure attack times are all ints, greater than zero
|
|
771
|
+
for attackTime in attackTimes:
|
|
772
|
+
|
|
773
|
+
if attackTime < 0:
|
|
774
|
+
|
|
775
|
+
raise ValueError("Attack times should be zero or positive (found " + str(attackTime) + ").")
|
|
776
|
+
|
|
777
|
+
# make sure attack volumes are all floats between 0.0 and 1.0 (inclusive).
|
|
778
|
+
for attackVolume in attackVolumes:
|
|
779
|
+
|
|
780
|
+
if attackVolume < 0.0 or 1.0 < attackVolume:
|
|
781
|
+
|
|
782
|
+
raise ValueError("Attack volumes should be between 0.0 and 1.0 (found " + str(attackVolume) + ").")
|
|
783
|
+
|
|
784
|
+
# all well, so update
|
|
785
|
+
self.attackTimes = attackTimes
|
|
786
|
+
self.attackVolumes = attackVolumes
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def getAttackTimesAndVolumes(self):
|
|
790
|
+
""" Returns list of attack times and corresponding volumes. No need for individual getter functions - these lists go together. """
|
|
791
|
+
|
|
792
|
+
return [self.attackTimes, self.attackVolumes]
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def setDelayTime(self, delayTime):
|
|
796
|
+
""" Sets delay time. """
|
|
797
|
+
|
|
798
|
+
# make input value is appropriate
|
|
799
|
+
if delayTime < 0:
|
|
800
|
+
|
|
801
|
+
raise ValueError("Delay time must 0 or greater (in milliseconds).")
|
|
802
|
+
|
|
803
|
+
# all well, so update
|
|
804
|
+
self.delayTime = delayTime
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def getDelayTime(self):
|
|
808
|
+
""" Returns delay time. """
|
|
809
|
+
|
|
810
|
+
return self.delayTime
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def setSustainVolume(self, sustainVolume):
|
|
814
|
+
""" Sets sustain volume. """
|
|
815
|
+
|
|
816
|
+
# make input value is appropriate
|
|
817
|
+
if sustainVolume < 0.0 or sustainVolume > 1.0:
|
|
818
|
+
|
|
819
|
+
raise ValueError("Sustain volume must be between 0.0 and 1.0.")
|
|
820
|
+
|
|
821
|
+
# all well, so update
|
|
822
|
+
self.sustainVolume = sustainVolume
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def getSustainVolume(self):
|
|
826
|
+
""" Returns sustain volume. """
|
|
827
|
+
|
|
828
|
+
return self.sustainVolume
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def setReleaseTime(self, releaseTime):
|
|
832
|
+
""" Sets release time. """
|
|
833
|
+
|
|
834
|
+
# make input value is appropriate
|
|
835
|
+
if releaseTime < 0:
|
|
836
|
+
|
|
837
|
+
raise ValueError("Release time must 0 or greater (in milliseconds).")
|
|
838
|
+
|
|
839
|
+
# all well, so update
|
|
840
|
+
self.releaseTime = releaseTime
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def getReleaseTime(self):
|
|
844
|
+
""" Returns release time. """
|
|
845
|
+
|
|
846
|
+
return self.releaseTime
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def performAttackDelaySustain(self, audioSample, volume, voice):
|
|
850
|
+
""" Applies the beginning of the envelope to the given voice of the provided audio sample. This involves setting up appropriate timers
|
|
851
|
+
to adjust volume, at appropriate times, as dictated by the envelope settings.
|
|
852
|
+
"""
|
|
853
|
+
|
|
854
|
+
# NOTE: In order to allow the same envelope to be re-used by different audio samples, we place inside the audio sample
|
|
855
|
+
# a dictionary of timers, indexed by voice. This way different audio samples will not compete with each other, if they are all
|
|
856
|
+
# using the same envelope.
|
|
857
|
+
#
|
|
858
|
+
# Each voice has its own list of timers - implementing the envelope, while it is sounding
|
|
859
|
+
# This way, we can stop these timers, if the voice sounds less time than what the envelope - not an error (we will try and do our best)
|
|
860
|
+
|
|
861
|
+
# initialize envelope timers for this audio sample
|
|
862
|
+
if "envelopeTimers" not in dir(audioSample): # is this the first time we see this audio sample?
|
|
863
|
+
|
|
864
|
+
audioSample.envelopeTimers = {} # yes, so initiliaze dictionary of envelope timers
|
|
865
|
+
|
|
866
|
+
# now, we have a dictionary of envelope timers
|
|
867
|
+
|
|
868
|
+
# next, initiliaze list of timers for this voice (we may assume that none exists...)
|
|
869
|
+
audioSample.envelopeTimers[voice] = []
|
|
870
|
+
|
|
871
|
+
# set initial volume to zero
|
|
872
|
+
audioSample.setVolume(volume = 0, delay = 2, voice = voice)
|
|
873
|
+
|
|
874
|
+
# initialize variables
|
|
875
|
+
maxVolume = volume # audio sample's requested volume... everything will be adjusted relative to that
|
|
876
|
+
nextTime = 0 # next time to begin volume adjustment - start at beginning of sound
|
|
877
|
+
|
|
878
|
+
# schedule attack timers
|
|
879
|
+
for attackTime, attackVolume in zip(self.attackTimes, self.attackVolumes):
|
|
880
|
+
|
|
881
|
+
# adjust volume appropriately
|
|
882
|
+
volume = int(maxVolume * attackVolume) # attackVolume ranges between 0.0 and 1.0, so we treat it as relative factor
|
|
883
|
+
|
|
884
|
+
# schedule volume change over this attack time
|
|
885
|
+
# NOTE: attackTime indicates how long this volume change should take!!!
|
|
886
|
+
timer = Timer2(nextTime, audioSample.setVolume, [volume, attackTime, voice], False)
|
|
887
|
+
#print "attack set - volume, delay, voice =", volume, nextTime, voice #***
|
|
888
|
+
|
|
889
|
+
# remember timer
|
|
890
|
+
audioSample.envelopeTimers[voice].append( timer )
|
|
891
|
+
|
|
892
|
+
# advance time
|
|
893
|
+
nextTime = nextTime + attackTime
|
|
894
|
+
|
|
895
|
+
# now, all attack timers have been created
|
|
896
|
+
|
|
897
|
+
# next, create timer to handle delay and sustain setting
|
|
898
|
+
volume = int(maxVolume * self.sustainVolume) # sustainVolume ranges between 0.0 and 1.0, so we treat it as relative factor
|
|
899
|
+
|
|
900
|
+
# schedule volume change over delay time
|
|
901
|
+
# NOTE: delay time indicates how long this volume change should take!!!
|
|
902
|
+
timer = Timer2(nextTime, audioSample.setVolume, [volume, self.delayTime, voice], False)
|
|
903
|
+
#print "delay set - volume, voice =", volume, voice #***
|
|
904
|
+
|
|
905
|
+
# remember timer
|
|
906
|
+
audioSample.envelopeTimers[voice].append( timer )
|
|
907
|
+
|
|
908
|
+
# beginning of envelope has been set up, so start timers to make things happen
|
|
909
|
+
for timer in audioSample.envelopeTimers[voice]:
|
|
910
|
+
timer.start()
|
|
911
|
+
|
|
912
|
+
# done!!!
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def performReleaseAndStop(self, audioSample, voice):
|
|
916
|
+
""" Applies the release time (fade out) to the given voice of the provided audioSample. """
|
|
917
|
+
|
|
918
|
+
# stop any remaining timers, and empty list
|
|
919
|
+
for timer in audioSample.envelopeTimers[voice]:
|
|
920
|
+
timer.stop()
|
|
921
|
+
|
|
922
|
+
# empty list of timers - they are not needed anymore (clean up for next use...)
|
|
923
|
+
del audioSample.envelopeTimers[voice]
|
|
924
|
+
|
|
925
|
+
# turn volume down to zero, slowly, over release time milliseconds
|
|
926
|
+
audioSample.setVolume(volume = 0, delay = self.releaseTime, voice = voice)
|
|
927
|
+
#print "release set - volume, voice =", 0, voice #***
|
|
928
|
+
|
|
929
|
+
# and schedule sound to stop, after volume has been turned down completely
|
|
930
|
+
someMoreTime = 5 # to give a little extra time for things to happen (just in case) - in milliseconds (avoids clicking...)
|
|
931
|
+
timer = Timer2(self.releaseTime + someMoreTime, audioSample.stop, [voice], False)
|
|
932
|
+
timer.start()
|
|
933
|
+
|
|
934
|
+
# done!!!
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _cleanupAudioSamples():
|
|
938
|
+
"""Stops and closes all active AudioSample players registered with atexit."""
|
|
939
|
+
# iterate over a copy because sample.__del__() will modify _activeAudioSamples
|
|
940
|
+
activeSamplesCopy = list(_activeAudioSamples)
|
|
941
|
+
for sample in activeSamplesCopy:
|
|
942
|
+
try:
|
|
943
|
+
sample.__del__()
|
|
944
|
+
except Exception as e:
|
|
945
|
+
filename = getattr(sample, 'filename', 'UnknownSample')
|
|
946
|
+
print(f"atexit: Error closing sample {filename}: {e}")
|
|
947
|
+
|
|
948
|
+
# After attempting to close all, _activeAudioSamples should ideally be empty
|
|
949
|
+
# if AudioSample.__del__() correctly removes items.
|
|
950
|
+
if _activeAudioSamples:
|
|
951
|
+
# this might indicate an issue if samples couldn't be removed or __del__ wasn't effective.
|
|
952
|
+
_activeAudioSamples.clear() # final attempt to clear the list.
|
|
953
|
+
|
|
954
|
+
# register cleanup function to be called at Python interpreter exit
|
|
955
|
+
atexit.register(_cleanupAudioSamples)
|
|
956
|
+
|
|
957
|
+
# Note on cleanup: sounddevice itself has an atexit handler that terminates PortAudio.
|
|
958
|
+
# Our cleanupAudioSamples function is a best-effort attempt to explicitly close all
|
|
959
|
+
# AudioSample instances and their underlying RealtimeAudioPlayer streams before that.
|
|
960
|
+
# The RealtimeAudioPlayer.close() method is designed to be robust and will not error
|
|
961
|
+
# if PortAudio has already been terminated by sounddevice's cleanup, preventing crashes
|
|
962
|
+
# if our atexit handler runs after sounddevice's.
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
########## tests ##############
|
|
966
|
+
|
|
967
|
+
if __name__ == '__main__':
|
|
968
|
+
a = AudioSample("Vundabar - Smell Smoke - 03 Tar Tongue.wav")
|
|
969
|
+
a.loop()
|