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 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()