CreativePython 0.0.0__py3-none-any.whl → 0.0.1__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.
@@ -0,0 +1,743 @@
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
+ _activeAudioSamples.append(self)
141
+
142
+ def play(self, start=0, size=-1, voice=0):
143
+ """
144
+ Play the corresponding sample once from the millisecond 'start' until the millisecond 'start'+'size'
145
+ (size == -1 means to the end). If 'start' and 'size' are omitted, play the complete sample.
146
+ If 'voice' is provided, the corresponding voice is used to play the sample (default is 0).
147
+ """
148
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
149
+ print(f"AudioSample.play: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
150
+ voice = 0
151
+
152
+ if not (isinstance(start, (int, float)) and start >= 0):
153
+ print(f"AudioSample.play: Warning - Invalid start time {start}ms. Must be a non-negative number. Using 0ms.")
154
+ start = 0
155
+
156
+ player = self._players[voice]
157
+ start_seconds = start / 1000.0
158
+ startAtBeginning = True
159
+
160
+ if start > 0:
161
+ player.setCurrentTime(start_seconds) # Sets player.playbackPosition
162
+ startAtBeginning = False
163
+
164
+ calculated_play_duration_source_frames = -1.0
165
+ loop_region_start_f = 0.0
166
+ if player.getFrameRate() > 0:
167
+ loop_region_start_f = (start / 1000.0) * player.getFrameRate() if start > 0 else 0.0
168
+
169
+ if size > 0: # size is in ms
170
+ size_seconds = size / 1000.0
171
+ frameRate = player.getFrameRate()
172
+ if frameRate > 0:
173
+ calculated_play_duration_source_frames = size_seconds * frameRate
174
+ else:
175
+ print(f"AudioSample.play: Warning - Could not determine valid frame rate for voice {voice}. 'size' parameter will be ignored.")
176
+ elif size == 0:
177
+ calculated_play_duration_source_frames = 0.0
178
+
179
+ # Store non-looping settings (or settings for a single iteration if size is given)
180
+ self._currentLoopSettings[voice] = {
181
+ 'active': False, # signifies it's a play-once (or play-duration)
182
+ 'loopCountTarget': 0, # ensures it's treated as non-looping by RTA if loop=False
183
+ 'loopRegionStartFrame': loop_region_start_f,
184
+ 'loopRegionEndFrame': -1.0, # not critical for non-looping, RTA uses targetEndSourceFrame based on duration
185
+ 'loopsPerformedCurrent': 0,
186
+ 'playDurationSourceFrames': calculated_play_duration_source_frames # store this for resume
187
+ }
188
+
189
+ # ensure the player is set to not loop for this single play command.
190
+ player.play(startAtBeginning=startAtBeginning,
191
+ loop=False,
192
+ playDurationSourceFrames=calculated_play_duration_source_frames,
193
+ loopRegionStartFrame=loop_region_start_f
194
+ # initialLoopsPerformed will be 0 (default) for a new play
195
+ )
196
+
197
+ self._isPausedFlags[voice] = False
198
+
199
+ def loop(self, times=-1, start=0, size=-1, voice=0):
200
+ """
201
+ Repeat the corresponding sample indefinitely (times = -1), or the specified number of times
202
+ from millisecond 'start' until millisecond 'start'+'size' (size == -1 means to the end).
203
+ If 'start' and 'size' are omitted, repeat the complete sample.
204
+ If 'voice' is provided, the corresponding voice is used to loop the sample (default is 0).
205
+ """
206
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
207
+ print(f"AudioSample.loop: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
208
+ voice = 0
209
+
210
+ if not (isinstance(start, (int, float)) and start >= 0):
211
+ print(f"AudioSample.loop: Warning - Invalid start time {start}ms. Must be a non-negative number. Using 0ms.")
212
+ start = 0
213
+
214
+ player = self._players[voice]
215
+ frameRate = player.getFrameRate()
216
+
217
+ loopRegionStartFrames = 0.0
218
+ if start > 0:
219
+ if frameRate > 0:
220
+ loopRegionStartFrames = (start / 1000.0) * frameRate
221
+ else:
222
+ print(f"AudioSample.loop: Warning - Invalid frame rate for voice {voice}. 'start' parameter might not work as expected.")
223
+
224
+ loopRegionEndFrames = -1.0 # Default to end of file
225
+ if size > 0: # size is in ms
226
+ if frameRate > 0:
227
+ startSeconds = start / 1000.0
228
+ sizeSeconds = size / 1000.0
229
+ loopRegionEndFrames = (startSeconds + sizeSeconds) * frameRate
230
+ # ensure end frame isn't before start frame due to rounding or tiny size
231
+ if loopRegionEndFrames <= loopRegionStartFrames:
232
+ 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'.")
233
+ loopRegionEndFrames = -1.0 # fallback to loop until end of file from start
234
+ else:
235
+ print(f"AudioSample.loop: Warning - Invalid frame rate for voice {voice}. 'size' parameter will be ignored, looping full file from 'start'.")
236
+ elif size == 0:
237
+ print("AudioSample.loop: Info - 'size=0' is not a valid duration for a loop segment. Looping entire file from 'start'.")
238
+ loopRegionEndFrames = -1.0 # loop entire file if size is 0
239
+
240
+
241
+ # Determine if player needs to be reset to the beginning of its (new) loop segment.
242
+ # This is generally true unless we are trying to resume a multi-loop sequence, which is not the API here.
243
+ startAtBeginningOfLoopSegment = True
244
+ if start > 0: # If start is specified, we always want to set the current time.
245
+ player.setCurrentTime(start / 1000.0)
246
+
247
+ # The `times` parameter from AudioSample API:
248
+ # -1: loop indefinitely (maps to RealtimeAudioPlayer loopCountTarget = -1)
249
+ # 0: play once (maps to RealtimeAudioPlayer loopCountTarget = 0, and loop=False effectively)
250
+ # >0: loop N times (maps to RealtimeAudioPlayer loopCountTarget = N)
251
+ actualLoopCountTarget = times
252
+ playerShouldLoop = True
253
+ if times == 0:
254
+ playerShouldLoop = False
255
+ actualLoopCountTarget = 0 # Play once through the segment
256
+ # print("AudioSample.loop: Info - 'times=0' means play segment once without looping.")
257
+
258
+ # 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}")
259
+
260
+ # Store loop settings for this voice
261
+ self._currentLoopSettings[voice] = {
262
+ 'active': playerShouldLoop,
263
+ 'loopCountTarget': actualLoopCountTarget,
264
+ 'loopRegionStartFrame': loopRegionStartFrames,
265
+ 'loopRegionEndFrame': loopRegionEndFrames,
266
+ 'loopsPerformedCurrent': 0, # Reset on new loop command
267
+ 'playDurationSourceFrames': -1.0 # Not used for active looping
268
+ }
269
+
270
+ player.play(startAtBeginning=startAtBeginningOfLoopSegment, # True to make it start from loop_region_start_frames
271
+ loop=playerShouldLoop,
272
+ playDurationSourceFrames=-1.0, # Not used for looping
273
+ loopRegionStartFrame=loopRegionStartFrames,
274
+ loopRegionEndFrame=loopRegionEndFrames,
275
+ loopCountTarget=actualLoopCountTarget)
276
+
277
+ self._isPausedFlags[voice] = False
278
+
279
+ def stop(self, voice=0):
280
+ """
281
+ Stops sample playback.
282
+ If optional 'voice' is provided, the corresponding voice is stopped (default is 0).
283
+ """
284
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
285
+ print(f"AudioSample.stop: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
286
+ voice = 0
287
+
288
+ player = self._players[voice]
289
+ player.stop(immediate=True) # Corresponds to API "Stops sample playback immediately"
290
+
291
+ # Reset loop settings for this voice to default non-looping
292
+ self._currentLoopSettings[voice] = {
293
+ 'active': False,
294
+ 'loopCountTarget': 0,
295
+ 'loopRegionStartFrame': 0.0,
296
+ 'loopRegionEndFrame': -1.0,
297
+ 'loopsPerformedCurrent': 0,
298
+ 'playDurationSourceFrames': -1.0
299
+ }
300
+ self._isPausedFlags[voice] = False # Reset pause state on stop
301
+
302
+ def isPlaying(self, voice=0):
303
+ """
304
+ Returns True if the sample is still playing, False otherwise.
305
+ If optional 'voice' is provided, the corresponding voice is checked
306
+ (default is 0).
307
+ """
308
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
309
+ print(f"AudioSample.isPlaying: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning False.")
310
+ return False
311
+
312
+ player = self._players[voice]
313
+ # player.isPlaying directly reflects if the RealtimeAudioPlayer is active.
314
+ # this will be False if stopped, paused (as pause calls player.stop(immediate=False)),
315
+ # or if playback naturally ended.
316
+ return player.isPlaying
317
+
318
+ def isPaused(self, voice=0):
319
+ """
320
+ Returns True if the specified voice of the sample is currently paused.
321
+ """
322
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
323
+ print(f"AudioSample.isPaused: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning False.")
324
+ return False
325
+ return self._isPausedFlags[voice]
326
+
327
+ def pause(self, voice=0):
328
+ """
329
+ Pauses sample playback (remembers current position for resume).
330
+ If optional 'voice' is provided, the corresponding voice is paused
331
+ (default is 0).
332
+ """
333
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
334
+ print(f"AudioSample.pause: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
335
+ voice = 0
336
+
337
+ player = self._players[voice]
338
+ # RealtimeAudioPlayer.stop(immediate=False) initiates a fade-out and stops the stream,
339
+ # preserving playbackPosition. This serves as our pause mechanism.
340
+ if player.isPlaying: # only pause if it's actually playing
341
+ # store current loops performed from the player *before* stopping it
342
+ current_loops_performed_by_player = player.getLoopsPerformed()
343
+ self._currentLoopSettings[voice]['loopsPerformedCurrent'] = current_loops_performed_by_player
344
+
345
+ player.stop(immediate=False)
346
+ self._isPausedFlags[voice] = True
347
+ else:
348
+ # if already stopped or paused, no action needed or print info
349
+ if self._isPausedFlags[voice]:
350
+ print(f"AudioSample.pause: Voice {voice} is already paused.")
351
+ else:
352
+ print(f"AudioSample.pause: Voice {voice} is not currently playing, cannot pause.")
353
+
354
+ def resume(self, voice=0):
355
+ """
356
+ Resumes sample playback (from the paused position).
357
+ If optional 'voice' is provided, the corresponding voice is resumed
358
+ (default is 0).
359
+ """
360
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
361
+ print(f"AudioSample.resume: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
362
+ voice = 0
363
+
364
+ player = self._players[voice]
365
+ loop_settings = self._currentLoopSettings[voice]
366
+
367
+ if self._isPausedFlags[voice]:
368
+ # this voice was explicitly paused by AudioSample.pause()
369
+ if player.isPlaying:
370
+ # This case should ideally not be hit if pause correctly stops the player.
371
+ # if it is hit, it means player is playing despite being paused from AudioSample's view.
372
+ print(f"AudioSample.resume: Voice {voice} was paused but player is already playing. Resuming anyway and clearing pause flag.")
373
+ # else:
374
+ # Expected path: paused and player is not playing, so resume.
375
+ # print(f"AudioSample.resume: Resuming explicitly paused voice {voice}.")
376
+
377
+ play_duration_for_resume = -1.0
378
+ if not loop_settings['active']: # if it was a single play being resumed
379
+ play_duration_for_resume = loop_settings.get('playDurationSourceFrames', -1.0)
380
+
381
+ player.play(
382
+ startAtBeginning=False, # resume from current player.playbackPosition
383
+ loop=loop_settings['active'],
384
+ playDurationSourceFrames=play_duration_for_resume,
385
+ loopRegionStartFrame=loop_settings['loopRegionStartFrame'],
386
+ loopRegionEndFrame=loop_settings['loopRegionEndFrame'],
387
+ loopCountTarget=loop_settings['loopCountTarget'],
388
+ initialLoopsPerformed=loop_settings['loopsPerformedCurrent'] # pass stored count
389
+ )
390
+ self._isPausedFlags[voice] = False
391
+ else:
392
+ # this voice was NOT explicitly paused by AudioSample.pause()
393
+ if player.isPlaying:
394
+ print(f"AudioSample.resume: Voice {voice} is already playing and was not paused!")
395
+ else:
396
+ print(f"AudioSample.resume: Voice {voice} was not paused via AudioSample.pause(). Call pause() first to use resume, or play() to start.")
397
+ # do not change _isPausedFlags[voice] here, it's already False.
398
+ # do not start playback.
399
+
400
+ def setFrequency(self, freq, voice=0):
401
+ """
402
+ Sets the sample frequency (in Hz).
403
+ If optional 'voice' is provided, the frequency of the corresponding voice is set
404
+ (default is 0).
405
+ """
406
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
407
+ print(f"AudioSample.setFrequency: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
408
+ voice = 0
409
+
410
+ if not (isinstance(freq, (int, float)) and freq > 0):
411
+ print(f"AudioSample.setFrequency: Warning - Invalid frequency value {freq}Hz. Must be a positive number. No change.")
412
+ return
413
+
414
+ player = self._players[voice]
415
+ player.setFrequency(float(freq)) # RealtimeAudioPlayer handles rate adjustment
416
+
417
+ self._currentFrequencies[voice] = float(freq)
418
+ # when frequency is set, pitch also changes in the player
419
+ self._currentPitches[voice] = player.getPitch()
420
+
421
+ def getFrequency(self, voice=0):
422
+ """
423
+ Returns the current sample frequency.
424
+ If optional 'voice' is provided, the frequency of the corresponding voice is returned
425
+ (default is 0).
426
+ """
427
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
428
+ print(f"AudioSample.getFrequency: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning frequency of voice 0.")
429
+ voice = 0
430
+ if not self._currentFrequencies: # should only happen if maxVoices was 0 or less.
431
+ print(f"AudioSample.getFrequency: Error - No voices available to get frequency from.")
432
+ return self.actualFrequency # fallback to baseFrequency of the sample itself
433
+
434
+ return self._currentFrequencies[voice]
435
+
436
+ def setPitch(self, pitch, voice=0):
437
+ """
438
+ Sets the sample pitch (0-127) through pitch shifting from sample's base pitch.
439
+ If optional 'voice' is provided, the pitch of the corresponding voice is set
440
+ (default is 0).
441
+ """
442
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
443
+ print(f"AudioSample.setPitch: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
444
+ voice = 0
445
+
446
+ if not (isinstance(pitch, (int, float)) and 0 <= pitch <= 127):
447
+ print(f"AudioSample.setPitch: Warning - Invalid pitch value {pitch}. Must be a number between 0 and 127. No change.")
448
+ return
449
+
450
+ player = self._players[voice]
451
+ player.setPitch(float(pitch)) # RealtimeAudioPlayer handles rate adjustment based on its basePitch
452
+
453
+ self._currentPitches[voice] = float(pitch)
454
+ # when pitch is set, frequency also changes in the player
455
+ self._currentFrequencies[voice] = player.getFrequency()
456
+
457
+ def getPitch(self, voice=0):
458
+ """
459
+ Returns the sample's current pitch (it may be different from the default pitch).
460
+ If optional 'voice' is provided, the pitch of the corresponding voice is returned
461
+ (default is 0).
462
+ """
463
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
464
+ print(f"AudioSample.getPitch: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning pitch of voice 0.")
465
+ voice = 0 # default to voice 0 if index is invalid
466
+ # If maxVoices is 0 (should not happen with proper init), this could still fail.
467
+ # However, __init__ should create at least one voice if voices >=1.
468
+ # If voices was 0, _currentPitches would be empty. This case should be rare.
469
+ if not self._currentPitches: # should only happen if maxVoices was 0 or less.
470
+ print(f"AudioSample.getPitch: Error - No voices available to get pitch from.")
471
+ return self.actualPitch # fallback to basePitch of the sample itself
472
+
473
+ return self._currentPitches[voice]
474
+
475
+ def getActualPitch(self):
476
+ """
477
+ Return sample's actual pitch.
478
+ """
479
+ return self.actualPitch
480
+
481
+
482
+ def getActualFrequency(self):
483
+ """
484
+ Return sample's actual frequency.
485
+ """
486
+ return self.actualFrequency
487
+
488
+ def setPanning(self, panning, voice=0):
489
+ """
490
+ Sets the panning of the sample (panning ranges from 0 – 127).
491
+ 0 is full left, 64 is center, 127 is full right.
492
+ If optional 'voice' is provided, the panning of the corresponding voice is set
493
+ (default is 0).
494
+ """
495
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
496
+ print(f"AudioSample.setPanning: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
497
+ voice = 0
498
+
499
+ if not (isinstance(panning, int) and 0 <= panning <= 127):
500
+ print(f"AudioSample.setPanning: Warning - Invalid panning value {panning}. Must be an integer between 0 and 127. No change.")
501
+ return
502
+
503
+ player = self._players[voice]
504
+ # convert API pan (0-127, 64=center) to player factor (-1.0 to 1.0)
505
+ # (api_pan - 63.5) / 63.5 ensures that 64 -> ~0.0, 0 -> -1.0, 127 -> 1.0
506
+ panFactor = (panning - 63.5) / 63.5
507
+ player.setPanFactor(panFactor)
508
+
509
+ self._currentPannings[voice] = panning
510
+
511
+ def getPanning(self, voice=0):
512
+ """
513
+ Returns the current panning of the sample (panning ranges from 0 – 127).
514
+ If optional 'voice' is provided, the panning of the corresponding voice is returned
515
+ (default is 0).
516
+ """
517
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
518
+ print(f"AudioSample.getPanning: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning panning of voice 0.")
519
+ voice = 0
520
+ if not self._currentPannings: # should only happen if maxVoices was 0 or less.
521
+ print(f"AudioSample.getPanning: Error - No voices available to get panning from.")
522
+ return 64 # default center panning
523
+
524
+ return self._currentPannings[voice]
525
+
526
+ def setVolume(self, volume, delay=2, voice=0):
527
+ """
528
+ Sets the volume (amplitude) of the sample (volume ranges from 0 – 127).
529
+ Optional delay indicates speed with which to adjust volume (in milliseconds – default is 2).
530
+ If voice is provided, the volume of the corresponding voice is set (default is 0).
531
+ """
532
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
533
+ print(f"AudioSample.setVolume: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Using voice 0.")
534
+ voice = 0
535
+
536
+ if not (isinstance(volume, int) and 0 <= volume <= 127):
537
+ print(f"AudioSample.setVolume: Warning - Invalid volume value {volume}. Must be an integer between 0 and 127. No change.")
538
+ return
539
+
540
+ if not (isinstance(delay, (int, float)) and delay >= 0):
541
+ print(f"AudioSample.setVolume: Warning - Invalid delay value {delay}ms. Must be non-negative. Using default behavior (near immediate change).")
542
+ delay = 2
543
+
544
+ player = self._players[voice]
545
+ targetVolumeFactor = volume / 127.0
546
+
547
+ # define a callback for the ramp to update the player's volume factor
548
+ def rampCallback(currentVolumeFactor):
549
+ player.setVolumeFactor(currentVolumeFactor)
550
+ # we update _currentVolumes with the target API volume, not the intermediate factors.
551
+
552
+ if delay > 0: # arbitrary threshold, e.g. > 5ms for a noticeable ramp
553
+ currentApiVolume = self._currentVolumes[voice]
554
+ currentVolumeFactor = currentApiVolume / 127.0
555
+
556
+ volumeRamp = LinearRamp(
557
+ delayMs=float(delay),
558
+ startValue=currentVolumeFactor,
559
+ endValue=targetVolumeFactor,
560
+ function=rampCallback,
561
+ stepMs=10 # default step for the ramp, can be adjusted
562
+ )
563
+ volumeRamp.start() # start volume ramp
564
+ # Note: _currentVolumes[voice] is updated at the end of the ramp by ramp_callback(endValue)
565
+ # or immediately if no ramp.
566
+ else:
567
+ # print(f"AudioSample.setVolume: Setting volume for voice {voice} to {volume} ({target_volume_factor:.2f}) immediately.")
568
+ player.setVolumeFactor(targetVolumeFactor)
569
+
570
+ self._currentVolumes[voice] = volume # store the target API volume
571
+
572
+ def getVolume(self, voice=0):
573
+ """
574
+ Returns the current volume (amplitude) of the sample (volume ranges from 0 – 127).
575
+ If optional voice is provided, the volume of the corresponding voice is returned (default is 0).
576
+ """
577
+ if not (isinstance(voice, int) and 0 <= voice < self.maxVoices):
578
+ print(f"AudioSample.getVolume: Warning - Invalid voice index {voice}. Must be 0-{self.maxVoices-1}. Returning volume of voice 0.")
579
+ voice = 0
580
+ if not self._currentVolumes: # should only happen if maxVoices was 0 or less.
581
+ print(f"AudioSample.getVolume: Error - No voices available to get volume from.")
582
+ # In __init__, initialVolume is set. If maxVoices=0, _currentVolumes is empty.
583
+ # return initialVolume of the sample if possible, or a default like 127 or 0.
584
+ return self.initialVolume if hasattr(self, 'initialVolume') else 127
585
+
586
+ return self._currentVolumes[voice]
587
+
588
+ def allocateVoiceForPitch(self, pitch):
589
+ """
590
+ Returns the next available free voice, and allocates it as associated with this pitch.
591
+ Returns None, if all voices / players are occupied.
592
+ """
593
+ if (type(pitch) == int) and (0 <= pitch <= 127): # a MIDI pitch?
594
+ # yes, so convert pitch from MIDI number (int) to Hertz (float)
595
+ pitch = noteToFreq(pitch)
596
+ elif type(pitch) != float: # if pitch a frequency (a float, in Hz)?
597
+ raise TypeError("Pitch (" + str(pitch) + ") should be an int (range 0 and 127) or float (such as 440.0).")
598
+
599
+ # now, assume pitch contains a frequency (float)
600
+
601
+ # get next free voice (if any)
602
+ voiceForThisPitch = self.getNextFreeVoice()
603
+
604
+ if voiceForThisPitch != None: # if a free voice exists...
605
+ # associate it with this pitch
606
+ if not self.voicesAllocatedToPitch.has_key(pitch): # new pitch (not sounding already)?
607
+ self.voicesAllocatedToPitch[pitch] = [voiceForThisPitch] # remember that this voice is playing this pitch
608
+ else: # there is at least one other voice playing this pitch, so...
609
+ self.voicesAllocatedToPitch[pitch].append( voiceForThisPitch ) # append this voice (mimicking MIDI standard for polyphony of same pitches!!!)
610
+
611
+ # now, self.pitchSounding remembers that this voice is associated with this pitch
612
+
613
+ # now, return new voice for this pitch (it could be None, if no free voices exist!)
614
+ return voiceForThisPitch
615
+
616
+ def getNextFreeVoice(self):
617
+ """
618
+ Returns the next available voice, i.e., a player that is not currently playing.
619
+ Returns None, if all voices / players are occupied.
620
+ """
621
+ if len(self.freeVoices) > 0: # are there some free voices?
622
+ freeVoice = self.freeVoices.pop(0) # get the first available one
623
+ else: # all voices are being used
624
+ freeVoice = None
625
+
626
+ return freeVoice
627
+
628
+ def getVoiceForPitch(self, pitch):
629
+ """
630
+ Returns the first voice (if any) associated with this pitch (there may be more than one - as we allow polyphony for the same pitch).
631
+ Returns None, if no voices are associated with this pitch.
632
+ """
633
+
634
+ if (type(pitch) == int) and (0 <= pitch <= 127): # a MIDI pitch?
635
+ # yes, so convert pitch from MIDI number (int) to Hertz (float)
636
+ pitch = noteToFreq(pitch)
637
+
638
+ elif type(pitch) != float: # if pitch a frequency (a float, in Hz)
639
+ raise TypeError("Pitch (" + str(pitch) + ") should be an int (range 0 and 127) or float (such as 440.0).")
640
+
641
+ # now, assume pitch contains a frequency (float)
642
+
643
+ voice = None # initialize
644
+
645
+ if self.voicesAllocatedToPitch.has_key(pitch) and len( self.voicesAllocatedToPitch[pitch] ) > 0: # does this pitch have voices allocated to it?
646
+ voice = self.voicesAllocatedToPitch[pitch][0] # first voice used for this pitch
647
+ else: # pitch is not currently sounding, so...
648
+ raise ValueError("Pitch (" + str(pitch) + ") is not currently playing!!!")
649
+
650
+ # now, let them know which voice was freed (if any)
651
+ return voice
652
+
653
+ def deallocateVoiceForPitch(self, pitch):
654
+ """
655
+ 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),
656
+ and puts it back in the pool of free voices - deallocates it.
657
+ """
658
+ if (type(pitch) == int) and (0 <= pitch <= 127): # a MIDI pitch?
659
+ # yes, so convert pitch from MIDI number (int) to Hertz (float)
660
+ pitch = noteToFreq(pitch)
661
+
662
+ elif type(pitch) != float: # if pitch a frequency (a float, in Hz)
663
+
664
+ raise TypeError("Pitch (" + str(pitch) + ") should be an int (range 0 and 127) or float (such as 440.0).")
665
+
666
+ # now, assume pitch contains a frequency (float)
667
+ if self.voicesAllocatedToPitch.has_key(pitch) and len( self.voicesAllocatedToPitch[pitch] ) > 0: # does this pitch have voices allocated to it?
668
+ freedVoice = self.voicesAllocatedToPitch[pitch].pop(0) # deallocate first voice used for this pitch
669
+ self.freeVoices.append( freedVoice ) # and return it back to the pool of free voices
670
+
671
+ else: # pitch is not currently sounding, so...
672
+ raise ValueError("Pitch (" + str(pitch) + ") is not currently playing!!!")
673
+
674
+ # done!!!
675
+
676
+ def getFrameRate(self):
677
+ """
678
+ Returns the sample's recording rate (e.g., 44100.0 Hz).
679
+ """
680
+ if not self._players:
681
+ print("AudioSample.getFrameRate: Warning - No audio players initialized for this sample.")
682
+ return None
683
+
684
+ # all players share the same frame rate as they are from the same file
685
+ return self._players[0].getFrameRate()
686
+
687
+ def __del__(self):
688
+ """
689
+ Destructor for AudioSample. Cleans up the sample's player(s).
690
+ """
691
+ # ensure close is idempotent and handles being called multiple times
692
+ if not hasattr(self, '_players') or not self._players: # check if already closed or not initialized
693
+ return
694
+
695
+ for i, player in enumerate(self._players):
696
+ if player: # check if player instance exists
697
+ try:
698
+ player.close()
699
+ except Exception as e:
700
+ print(f"AudioSample.close: Error closing player {i} for '{self.filename}': {e}")
701
+ self._players = [] # clear the list to help with garbage collection and prevent reuse
702
+
703
+ # remove from global list if present
704
+ if self in _activeAudioSamples:
705
+ try:
706
+ _activeAudioSamples.remove(self)
707
+ except ValueError:
708
+ # this can happen if close() is called multiple times.
709
+ pass
710
+
711
+ def _cleanupAudioSamples():
712
+ """Stops and closes all active AudioSample players registered with atexit."""
713
+ # iterate over a copy because sample.__del__() will modify _activeAudioSamples
714
+ activeSamplesCopy = list(_activeAudioSamples)
715
+ for sample in activeSamplesCopy:
716
+ try:
717
+ sample.__del__()
718
+ except Exception as e:
719
+ filename = getattr(sample, 'filename', 'UnknownSample')
720
+ print(f"atexit: Error closing sample {filename}: {e}")
721
+
722
+ # After attempting to close all, _activeAudioSamples should ideally be empty
723
+ # if AudioSample.__del__() correctly removes items.
724
+ if _activeAudioSamples:
725
+ # this might indicate an issue if samples couldn't be removed or __del__ wasn't effective.
726
+ _activeAudioSamples.clear() # final attempt to clear the list.
727
+
728
+ # register cleanup function to be called at Python interpreter exit
729
+ atexit.register(_cleanupAudioSamples)
730
+
731
+ # Note on cleanup: sounddevice itself has an atexit handler that terminates PortAudio.
732
+ # Our cleanupAudioSamples function is a best-effort attempt to explicitly close all
733
+ # AudioSample instances and their underlying RealtimeAudioPlayer streams before that.
734
+ # The RealtimeAudioPlayer.close() method is designed to be robust and will not error
735
+ # if PortAudio has already been terminated by sounddevice's cleanup, preventing crashes
736
+ # if our atexit handler runs after sounddevice's.
737
+
738
+
739
+ ########## tests ##############
740
+
741
+ if __name__ == '__main__':
742
+ a = AudioSample("Vundabar - Smell Smoke - 03 Tar Tongue.wav")
743
+ a.loop()