CreativePython 0.0.2__py3-none-any.whl → 0.0.3__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.
_RealtimeAudioPlayer.py CHANGED
@@ -1,5 +1,5 @@
1
1
  #######################################################################################
2
- # RealtimeAudioPlayer.py Version 1.0 19-May-2025
2
+ # _RealtimeAudioPlayer.py Version 1.0 22-Aug-2025
3
3
  # Trevor Ritchie, Taj Ballinger, and Bill Manaris
4
4
  #
5
5
  #######################################################################################
@@ -8,8 +8,6 @@
8
8
  #
9
9
  #######################################################################################
10
10
  # TODO:
11
- # - fade in/out logic is incomplete. We can probably remove it to avoid overhead.
12
- # Thought it might be nice to prevent popping sounds.
13
11
  #
14
12
  #######################################################################################
15
13
 
@@ -19,112 +17,165 @@ import numpy as np # for array operations
19
17
  import os # for file path operations
20
18
  import math # for logarithmic calculations in pitch/frequency conversions
21
19
 
22
- # helper functions
20
+
21
+ #### Helper Functions #################################################################
22
+
23
+ def freqToNote(frequency):
24
+ """Converts frequency to the closest MIDI note number with pitch bend value
25
+ for finer control. A4 corresponds to the note number 69 (concert pitch
26
+ is set to 440Hz by default). The default pitch bend range is 4 half tones,
27
+ and ranges from -8191 to +8192 (0 means no pitch bend).
28
+ """
29
+
30
+ concertPitch = 440.0 # 440Hz
31
+ bendRange = 4 # 4 semitones (2 below, 2 above)
32
+
33
+ x = math.log(frequency / concertPitch, 2) * 12 + 69
34
+ pitch = round(x)
35
+ pitchBend = round((x - pitch) * 8192 / bendRange * 2)
36
+
37
+ return int(pitch), int(pitchBend)
38
+
39
+
23
40
  def noteToFreq(pitch):
24
- """Converts a MIDI pitch to frequency. A4=69 is 440Hz."""
25
- return 440.0 * (2**((pitch - 69) / 12.0))
41
+ """Converts a MIDI pitch to the corresponding frequency. A4 corresponds to the note number 69 (concert pitch
42
+ is set to 440Hz by default).
43
+ """
44
+
45
+ concertPitch = 440.0 # 440Hz
46
+
47
+ frequency = concertPitch * 2 ** ( (pitch - 69) / 12.0 )
48
+
49
+ return frequency
26
50
 
27
- def freqToNote(hz):
28
- """Converts frequency in Hz to MIDI pitch (float for microtonal accuracy)."""
29
- if hz <= 0: return 0.0
30
- return 69.0 + 12.0 * math.log2(hz / 440.0)
51
+
52
+ ##### Real-Time Audio Player Class ########################################################
31
53
 
32
54
  class _RealtimeAudioPlayer:
55
+ """
56
+ This class is used by AudioSample to provide low-level, realtime audio playback
57
+ functionality. AudioSample acts as a higher-level interface for musical and polyphonic
58
+ control, but delegates all actual audio streaming, pitch/frequency shifting, volume, and
59
+ panning operations to _RealtimeAudioPlayer. By encapsulating the playback logic here,
60
+ AudioSample can manage multiple voices, envelopes, and advanced features without
61
+ duplicating audio I/O code.
62
+ """
63
+
33
64
  def __init__(self, filepath, loop=False, actualPitch=69, chunkSize=1024):
65
+ """
66
+ Initialize a realtime audio player for the specified audio file.
67
+
68
+ filepath: path to the audio file to load and play
69
+ loop: whether to loop the audio playback (default: False)
70
+ actualPitch: MIDI pitch (0-127) representing the base frequency of the audio (default: 69 for A4)
71
+ chunkSize: size of audio chunks for realtime processing (default: 1024 frames)
72
+ """
73
+
74
+ # validate that the audio file exists
34
75
  if not os.path.isfile(filepath):
35
76
  raise ValueError(f"File not found: {filepath}")
36
77
 
37
- self.filepath = filepath
78
+ self.filepath = filepath # store the file path for reference
38
79
 
80
+ # load the audio file using soundfile library
39
81
  try:
40
82
  self.audioData, self.sampleRate = sf.read(filepath, dtype='float32')
83
+
41
84
  except Exception as e:
42
85
  print(f"Error loading audio file with soundfile: {e}")
43
86
  raise
44
87
 
88
+ # analyze audio file structure and validate format compatibility
45
89
  if self.audioData.ndim == 1:
46
- self.numChannels = 1
90
+ self.numChannels = 1 # single channel (mono) audio
47
91
  self.numFrames = len(self.audioData)
92
+
48
93
  elif self.audioData.ndim == 2:
49
- self.numChannels = self.audioData.shape[1]
94
+ self.numChannels = self.audioData.shape[1] # multi-channel audio (stereo = 2)
50
95
  self.numFrames = self.audioData.shape[0]
51
- if self.numChannels > 2: # ensure we only handle mono or stereo for now
96
+
97
+ if self.numChannels > 2: # restrict to mono/stereo for current implementation
52
98
  raise ValueError(f"Unsupported number of channels: {self.numChannels}. Max 2 channels supported.")
99
+
53
100
  else:
54
101
  raise ValueError(f"Unexpected audio data dimensions: {self.audioData.ndim}")
55
102
 
103
+ # check if the audio file contains any actual audio data
56
104
  if self.numFrames == 0:
57
105
  print(f"Warning: Audio file '{os.path.basename(self.filepath)}' contains zero audio frames and is unplayable.")
58
106
 
59
- # playback state attributes
60
- self.isPlaying = False
61
- self.playbackPosition = 0.0 # in frames
62
- self.looping = loop
63
- self.rateFactor = 1.0
64
- self.volumeFactor = 1.0
65
-
66
- # panning
67
- self.panTargetFactor = 0.0
68
- self.currentPanFactor = 0.0
69
- self.panInitialFactor = 0.0
70
- self.panSmoothingDurationMs = 100
71
- self.panSmoothingTotalFrames = max(1, int(self.sampleRate * (self.panSmoothingDurationMs / 1000.0)))
107
+ # initialize playback state attributes
108
+ self.isPlaying = False # track whether audio is currently playing
109
+ self.playbackPosition = 0.0 # current playback position in frames
110
+ self.looping = loop # whether to loop the audio
111
+ self.rateFactor = 1.0 # playback speed multiplier (1.0 = normal speed)
112
+ self.volumeFactor = 1.0 # volume multiplier (1.0 = normal volume)
113
+
114
+ # initialize panning attributes for stereo positioning
115
+ self.panTargetFactor = 0.0 # target pan position (-1.0 = left, 0.0 = center, 1.0 = right)
116
+ self.currentPanFactor = 0.0 # current pan position
117
+ self.panInitialFactor = 0.0 # pan position when smoothing began
118
+ self.panSmoothingDurationMs = 100 # duration of pan smoothing in milliseconds
119
+ self.panSmoothingTotalFrames = max(1, int(self.sampleRate * (self.panSmoothingDurationMs / 1000.0))) # convert to frames
72
120
  self.panSmoothingFramesProcessed = self.panSmoothingTotalFrames # start as if complete
73
121
 
74
- # pitch/frequency
122
+ # initialize pitch and frequency attributes
75
123
  validPitchProvided = False
76
124
  if isinstance(actualPitch, (int, float)):
77
125
  tempPitch = float(actualPitch)
78
- if 0 <= tempPitch <= 127:
126
+
127
+ if 0 <= tempPitch <= 127: # validate MIDI pitch range
79
128
  self.basePitch = tempPitch
80
- self.baseFrequency = noteToFreq(self.basePitch)
129
+ self.baseFrequency = noteToFreq(self.basePitch) # convert MIDI pitch to frequency
81
130
  validPitchProvided = True
82
131
 
83
132
  if not validPitchProvided:
84
- # This case handles:
85
- # 1. Invalid types for actualPitch.
86
- # 2. MIDI pitches (int or float) outside the 0-127 range.
133
+ # handle invalid pitch values by defaulting to A4 (440Hz)
87
134
  print(f"Warning: Invalid or out-of-range actualPitch ({actualPitch}) provided for '{os.path.basename(self.filepath)}'. Expected MIDI pitch (int/float) 0-127. Defaulting to A4 (69 / 440Hz).")
88
- self.basePitch = 69.0 # default MIDI A4
89
- self.baseFrequency = noteToFreq(self.basePitch) # default 440 Hz
90
-
91
- # fades (master)
92
- self.fadeInDurationMs = 20
93
- self.fadeInTotalFrames = max(1, int(self.sampleRate * (self.fadeInDurationMs / 1000.0)))
94
- self.fadeInFramesProcessed = 0
95
- self.isApplyingFadeIn = False
96
-
97
- self.fadeOutDurationMs = 30
98
- self.fadeOutTotalFrames = max(1, int(self.sampleRate * (self.fadeOutDurationMs / 1000.0)))
99
- self.fadeOutFramesProcessed = 0
100
- self.isApplyingFadeOut = False
101
- self.isFadingOutToStop = False
102
-
103
- # fades (seek)
104
- self.isFadingOutToSeek = False
105
- self.seekTargetFrameAfterFade = 0.0
106
-
107
- # sounddevice stream
108
- self.sdStream = None
109
- self.chunkSize = chunkSize
110
-
111
- # internal
112
- self.playbackEndedNaturally = False
113
- self.playDurationSourceFrames = -1.0 # added for specific play duration
114
- self.targetEndSourceFrame = -1.0 # added for specific play duration
115
-
116
- # loop control attributes
117
- self.loopRegionStartFrame = 0.0
118
- self.loopRegionEndFrame = -1.0 # -1 means to end of file for looping
119
- self.loopCountTarget = -1 # -1 for infinite, 0 means no loop (play once), 1+ for specific counts
120
- self.loopsPerformed = 0
121
- if self.looping and self.loopCountTarget == -1: # default constructor loop is infinite
122
- pass # loopCountTarget remains -1
123
- elif not self.looping:
124
- self.loopCountTarget = 0 # play once then stop if loop=False initially
135
+ self.basePitch = 69.0 # default MIDI A4
136
+ self.baseFrequency = noteToFreq(self.basePitch) # default 440 Hz
137
+
138
+ # initialize fade-in attributes for smooth audio start (avoid pops/cracks)
139
+ self.fadeInDurationMs = 20 # fade-in duration in milliseconds
140
+ self.fadeInTotalFrames = max(1, int(self.sampleRate * (self.fadeInDurationMs / 1000.0))) # convert to frames
141
+ self.fadeInFramesProcessed = 0 # frames processed during current fade-in
142
+ self.isApplyingFadeIn = False # whether fade-in is currently active
143
+
144
+ # initialize fade-out attributes for smooth audio stop (avoid pops/cracks)
145
+ self.fadeOutDurationMs = 20 # fade-out duration in milliseconds
146
+ self.fadeOutTotalFrames = max(1, int(self.sampleRate * (self.fadeOutDurationMs / 1000.0))) # convert to frames
147
+ self.fadeOutFramesProcessed = 0 # frames processed during current fade-out
148
+ self.isApplyingFadeOut = False # whether fade-out is currently active
149
+ self.isFadingOutToStop = False # whether fade-out is leading to a stop
150
+
151
+ # initialize seek fade attributes for smooth position changes
152
+ self.isFadingOutToSeek = False # whether fade-out is leading to a seek operation
153
+ self.seekTargetFrameAfterFade = 0.0 # target frame position after seek fade completes
154
+
155
+ # initialize sounddevice stream attributes
156
+ self.sdStream = None # sounddevice audio stream for realtime playback
157
+ self.chunkSize = chunkSize # size of audio chunks for processing
158
+
159
+ # initialize internal state attributes
160
+ self.playbackEndedNaturally = False # whether playback ended naturally (not stopped)
161
+ self.playDurationSourceFrames = -1.0 # specific play duration in source frames (-1 = play to end)
162
+ self.targetEndSourceFrame = -1.0 # target end frame for specific play duration (-1 = play to end)
163
+
164
+ # initialize loop control attributes
165
+ self.loopRegionStartFrame = 0.0 # start frame of loop region
166
+ self.loopRegionEndFrame = -1.0 # end frame of loop region (-1 means to end of file)
167
+ self.loopCountTarget = -1 # target loop count (-1 = infinite, 0 = no loop, 1+ = specific count)
168
+ self.loopsPerformed = 0 # number of loops completed so far
169
+
170
+ if self.looping and self.loopCountTarget == -1: # default constructor loop is infinite
171
+ pass # loopCountTarget remains -1
172
+
173
+ elif not self.looping: # not looping
174
+ self.loopCountTarget = 0 # play once then stop
125
175
 
126
176
  # print(f"For '{os.path.basename(self.filepath)}', BasePitch={self.basePitch:.2f}, BaseFreq={self.baseFrequency:.2f}Hz.")
127
177
 
178
+
128
179
  def _findNextZeroCrossing(self, startFrameFloat, searchWindowFrames=256):
129
180
  """
130
181
  Finds the nearest zero-crossing at or after startFrameFloat.
@@ -132,137 +183,176 @@ class _RealtimeAudioPlayer:
132
183
  Returns the frame index (float) of the sample that is at or just after the zero-crossing.
133
184
  If no crossing is found within the window, returns the original startFrame, clamped to audio bounds.
134
185
  """
186
+
135
187
  startFrame = int(math.floor(startFrameFloat))
136
188
  startFrame = max(0, min(startFrame, self.numFrames - 1))
137
189
 
138
- # ensure search does not go out of bounds
190
+ # limit search window to prevent going beyond audio data boundaries
139
191
  endSearchFrame = min(self.numFrames - 1, startFrame + searchWindowFrames)
140
192
 
141
193
  if startFrame >= self.numFrames -1: # if already at or past the second to last frame
142
194
  return float(min(startFrame, self.numFrames -1))
143
195
 
144
- for i in range(startFrame, endSearchFrame):
196
+ # iterate through the audio frames in the search window to find a zero-crossing
197
+ for frameIdx in range(startFrame, endSearchFrame):
145
198
  currentSample = 0.0
146
199
  nextSample = 0.0
147
200
 
148
- if self.numChannels == 1:
149
- currentSample = self.audioData[i]
150
- if i + 1 < self.numFrames:
151
- nextSample = self.audioData[i+1]
201
+ if self.numChannels == 1: # mono audio
202
+ currentSample = self.audioData[frameIdx]
203
+ if frameIdx + 1 < self.numFrames:
204
+ nextSample = self.audioData[frameIdx + 1]
152
205
  else:
153
- return float(i) # reached end
154
- elif self.numChannels >= 2: # use left channel for stereo or more
155
- currentSample = self.audioData[i, 0]
156
- if i + 1 < self.numFrames:
157
- nextSample = self.audioData[i+1, 0]
206
+ return float(frameIdx) # reached end
207
+
208
+ elif self.numChannels >= 2: # stereo (or more)
209
+ currentSample = self.audioData[frameIdx, 0]
210
+ if frameIdx + 1 < self.numFrames:
211
+ nextSample = self.audioData[frameIdx + 1, 0]
158
212
  else:
159
- return float(i) # reached end
213
+ return float(frameIdx) # reached end
160
214
 
215
+ # is current sample exactly zero? (a zero-crossing point)
161
216
  if currentSample == 0.0:
162
- return float(i)
217
+ return float(frameIdx) # return frame index
218
+
219
+ # is there a sign change between currentSample and nextSample?
220
+ # ...which indicates a zero-crossing between these two samples
163
221
  if (currentSample > 0 and nextSample <= 0) or \
164
222
  (currentSample < 0 and nextSample >= 0):
165
- # choose the one closer to zero, or simply i+1
166
- # for simplicity, return i+1 as it's after the crossing
167
- return float(i + 1)
223
+ # return the frame index just after the crossing (i+1)
224
+ return float(frameIdx + 1) # closest we can get to zero-crossing
168
225
 
169
226
  return float(startFrame) # no crossing found in window
170
227
 
228
+
171
229
  def setRateFactor(self, factor):
172
- if not isinstance(factor, (int, float)):
173
- return
174
- if factor <= 0:
175
- self.rateFactor = 0.00001 # avoid zero or negative, effectively silent/pause
230
+ # check if the provided factor is a number (int or float)
231
+ if isinstance(factor, (int, float)):
232
+
233
+ # if factor is zero or negative, set to a very small positive value to effectively pause playback
234
+ if factor <= 0:
235
+ self.rateFactor = 0.00001 # avoid zero or negative, effectively silent/pause
236
+
237
+ else:
238
+ # otherwise, set the rate factor to the given value (as float)
239
+ self.rateFactor = float(factor)
240
+ # print(f"Set to {self.rateFactor:.4f}")
241
+
176
242
  else:
177
- self.rateFactor = float(factor)
178
- # print(f"Set to {self.rateFactor:.4f}")
243
+ # if input is not a number, default to 1x speed
244
+ self.rateFactor = 1.0
245
+
179
246
 
180
247
  def getRateFactor(self):
181
- return self.rateFactor
248
+ return self.rateFactor # return rate factor
249
+
182
250
 
183
251
  def setVolumeFactor(self, factor):
184
- if not isinstance(factor, (int, float)):
252
+ if isinstance(factor, (int, float)):
253
+ # valid factor, so set volume
254
+ self.volumeFactor = max(0.0, min(1.0, float(factor)))
255
+ # print(f"Set to {self.volumeFactor:.3f}")
256
+
257
+ else:
258
+ # factor is invalid type, so default to full volume
185
259
  self.volumeFactor = 1.0
186
- return
187
- self.volumeFactor = max(0.0, min(1.0, float(factor)))
188
- # print(f"Set to {self.volumeFactor:.3f}")
260
+
189
261
 
190
262
  def getVolumeFactor(self):
191
- return self.volumeFactor
263
+ return self.volumeFactor # return volume factor
264
+
192
265
 
193
266
  def setPanFactor(self, panFactor):
267
+ # clamp panFactor to a float in [-1.0, 1.0]; if invalid, default to center (0.0)
194
268
  if not isinstance(panFactor, (int, float)):
195
- clampedPanFactor = 0.0
269
+ clampedPanFactor = 0.0 # not a number, so use center
270
+
196
271
  else:
197
- clampedPanFactor = max(-1.0, min(1.0, float(panFactor)))
272
+ clampedPanFactor = max(-1.0, min(1.0, float(panFactor))) # clamp to valid range
273
+
274
+ # if the new pan target is different enough from the current target, start smoothing ramp
275
+ if abs(self.panTargetFactor - clampedPanFactor) > 0.001: # significant change
276
+ self.panTargetFactor = clampedPanFactor # set new pan target
277
+ self.panInitialFactor = self.currentPanFactor # remember current pan as ramp start
278
+ self.panSmoothingFramesProcessed = 0 # reset smoothing progress
198
279
 
199
- if abs(self.panTargetFactor - clampedPanFactor) > 0.001: # if target changes significantly
200
- self.panTargetFactor = clampedPanFactor
201
- self.panInitialFactor = self.currentPanFactor # store current actual pan as start of ramp
202
- self.panSmoothingFramesProcessed = 0 # reset to start smoothing ramp
203
280
 
204
281
  def getPanFactor(self):
205
- return self.panTargetFactor
282
+ return self.panTargetFactor # return pan factor
283
+
206
284
 
207
285
  def setFrequency(self, targetFrequencyHz):
208
- if not isinstance(targetFrequencyHz, (int, float)):
209
- return
210
- if self.baseFrequency <= 0:
211
- return
212
- if targetFrequencyHz <= 0:
213
- self.setRateFactor(0.00001)
214
- return
215
- newRateFactor = targetFrequencyHz / self.baseFrequency
216
- self.setRateFactor(newRateFactor)
286
+ if isinstance(targetFrequencyHz, (int, float)) and self.baseFrequency > 0:
287
+
288
+ if targetFrequencyHz > 0: # frequency is valid
289
+ newRateFactor = targetFrequencyHz / self.baseFrequency
290
+ self.setRateFactor(newRateFactor)
291
+
292
+ else: # target frequency too small
293
+ self.setRateFactor(0.00001) # avoid zero or negative, effectively silent/pause
294
+
217
295
 
218
296
  def getFrequency(self):
219
- return self.baseFrequency * self.rateFactor
297
+ # calculate current frequency based on base and rate
298
+ currentFreq = self.baseFrequency * self.rateFactor
299
+ return currentFreq # return current frequency
300
+
220
301
 
221
302
  def setPitch(self, midiPitch):
222
- if not (isinstance(midiPitch, (int, float)) and 0 <= midiPitch <= 127):
223
- return
224
- targetFrequencyHz = noteToFreq(float(midiPitch))
225
- self.setFrequency(targetFrequencyHz)
303
+ # set the playback pitch by converting midiPitch (0-127) to frequency and updating rate factor
304
+ if (isinstance(midiPitch, (int, float)) and 0 <= midiPitch <= 127):
305
+ targetFrequencyHz = noteToFreq(float(midiPitch)) # convert midi pitch to frequency
306
+ self.setFrequency(targetFrequencyHz) # set playback frequency accordingly
307
+
226
308
 
227
309
  def getPitch(self):
228
- currentFreq = self.getFrequency()
229
- return freqToNote(currentFreq)
310
+ currentFreq = self.getFrequency() # get freq
311
+ currentPitch = freqToNote(currentFreq) # convert to pitch
312
+ return currentPitch # return current pitch
230
313
 
231
- def getActualPitch(self):
232
- return self.basePitch
233
314
 
234
- def getActualFrequency(self):
235
- return self.baseFrequency
315
+ def getBasePitch(self):
316
+ return self.basePitch # original pitch of the sample
317
+
318
+
319
+ def getBaseFrequency(self):
320
+ return self.baseFrequency # original frequency of the sample
321
+
236
322
 
237
323
  def getFrameRate(self):
238
- return self.sampleRate
324
+ return self.sampleRate # sample rate of the audio
325
+
239
326
 
240
327
  def getCurrentTime(self):
241
- return self.playbackPosition / self.sampleRate
328
+ # calculate current time based on position and sample rate
329
+ currentTime = self.playbackPosition / self.sampleRate
330
+ return currentTime # return current time
331
+
242
332
 
243
333
  def setCurrentTime(self, timeSeconds):
244
- """Sets the current playback position.
245
- Full fade-seek-fade logic will be implemented later.
246
- For now, directly sets position and finds zero-crossing.
247
- """
334
+ # check that timeSeconds is a valid non-negative number. if not, default to 0.0
248
335
  if not isinstance(timeSeconds, (int, float)) or timeSeconds < 0:
249
336
  timeSeconds = 0.0
250
337
 
338
+ # convert the requested time in seconds to a floating-point frame index
251
339
  originalTargetFrameFloat = timeSeconds * self.sampleRate
252
340
 
253
341
  # basic ZC adjustment for now, will be enhanced with fade-seek-fade
254
342
  actualTargetFrame = self.findNextZeroCrossing(originalTargetFrameFloat)
255
343
 
256
- # if playing and conditions met for smooth seek (TODO: full logic from plan)
257
- # for now, direct set:
344
+ # if playing and conditions met for smooth seek
258
345
  if actualTargetFrame >= self.numFrames and not self.looping:
346
+ # set playback position to the requested frame, or to the end if beyond available frames
259
347
  self.playbackPosition = float(self.numFrames -1)
260
348
  self.playbackEndedNaturally = True
349
+
261
350
  else:
262
- self.playbackPosition = actualTargetFrame
263
- self.playbackEndedNaturally = False # reset if jumping
351
+ self.playbackPosition = actualTargetFrame # set playback position to the next zero crossing
352
+ self.playbackEndedNaturally = False # reset natural end flag if jumping
353
+
264
354
 
265
- # --- Playback Control Methods (play, stop, close) and audioCallback will be next ---
355
+ ### Playback Control Methods ########################################################################
266
356
 
267
357
  def audioCallback(self, outdata, frames, time, status):
268
358
  """
@@ -284,15 +374,17 @@ class _RealtimeAudioPlayer:
284
374
  output underflow) have occurred. It's good practice to check
285
375
  this, though for simplicity in many examples it might be ignored.
286
376
  """
287
- if status:
288
- # print(f"Status flags: {status}") # keep this commented unless debugging status
289
- pass # pass for now to avoid console noise unless specific status handling is added
377
+
378
+ # if status:
379
+ # print(f"Status flags: {status}") # keep this commented unless debugging status
290
380
 
291
381
  # failsafe for zero-frame audio, though play() should prevent this stream from starting.
292
382
  if self.numFrames == 0:
293
383
  outdata.fill(0) # fill the output buffer with silence.
384
+
294
385
  if self.isPlaying: # this should ideally not be true if play() did its job
295
386
  self.isPlaying = False # ensure playback state is consistent.
387
+
296
388
  raise sd.CallbackStop # stop the callback immediately, as there's no audio to play.
297
389
 
298
390
  # If not playing or rate is effectively zero, output silence.
@@ -301,6 +393,7 @@ class _RealtimeAudioPlayer:
301
393
  # without needing to go through the whole processing loop.
302
394
  if not self.isPlaying or self.rateFactor <= 0.000001:
303
395
  outdata.fill(0) # fill the output buffer with silence.
396
+
304
397
  if self.isApplyingFadeOut and self.isFadingOutToStop and self.rateFactor <= 0.000001:
305
398
  # If a fade-out to stop was in progress and the rate also became zero (e.g. set externally),
306
399
  # ensure the player state is fully stopped.
@@ -312,97 +405,124 @@ class _RealtimeAudioPlayer:
312
405
 
313
406
  return # exit the callback early, providing silence.
314
407
 
315
- numOutputChannels = outdata.shape[1]
408
+ numOutputChannels = outdata.shape[1] # get number of output channels (1=mono, 2=stereo)
409
+
316
410
  # initialize chunkBuffer matching the output stream's channel count and frame count for this callback.
317
411
  # This buffer will be filled with processed audio samples one by one before being copied to `outdata`.
318
412
  # Using an intermediate buffer like this is common for clarity and for complex processing steps.
319
413
  chunkBuffer = np.zeros((frames, numOutputChannels), dtype=np.float32)
320
414
 
321
415
  for i in range(frames): # per-sample processing loop
416
+
322
417
  if not self.isPlaying: # check if stop was called or playback ended within the loop
418
+
323
419
  # If isPlaying became false (e.g., due to a fade-out completing and setting isPlaying to False,
324
420
  # or an external stop() call), we should fill the rest of this chunk with silence
325
421
  # and then break out of this sample-processing loop.
326
422
  chunkBuffer[i:] = 0.0 # fill remaining part of the buffer with silence
327
423
  break # exit per-sample loop
424
+ # TODO: rewrite to avoid early break
328
425
 
329
- # --- Determine current sample value with interpolation (and hard loop if enabled) ---
426
+ #### Determine current sample value with interpolation (and hard loop if enabled)
330
427
  # To play audio at different speeds (self.rateFactor != 1.0) or for smooth playback,
331
428
  # we often need a sample value that lies *between* two actual data points in our audioData.
332
429
  # Linear interpolation is a common way to estimate this value.
333
- readPosFloat = self.playbackPosition # This is a floating-point number indicating the conceptual read position.
334
- readPosInt1 = int(math.floor(readPosFloat)) # The integer part, an index to an actual sample.
335
- readPosInt2 = readPosInt1 + 1 # The next actual sample index.
336
- fraction = readPosFloat - readPosInt1 # The fractional part, how far between readPosInt1 and readPosInt2 we are.
430
+ readPosFloat = self.playbackPosition # float indicating the conceptual read position
431
+ readPosInt1 = int(math.floor(readPosFloat)) # the integer part, an index to an actual sample
432
+ readPosInt2 = readPosInt1 + 1 # next actual sample index
433
+ fraction = readPosFloat - readPosInt1 # the fractional part, how far between readPosInt1 and readPosInt2 we are.
337
434
 
338
435
  # Clamp read positions to be safe for array access, *after* potential looping adjustment
339
436
  # This ensures that we don't try to read outside the bounds of our audioData array.
340
- readPosInt1 = max(0, min(readPosInt1, self.numFrames - 1))
341
- readPosInt2 = max(0, min(readPosInt2, self.numFrames - 1)) # ensures readPosInt2 is also valid
437
+ readPosInt1 = max(0, min(readPosInt1, self.numFrames - 1)) # ensure read position 1 is valid
438
+ readPosInt2 = max(0, min(readPosInt2, self.numFrames - 1)) # ensure read position 2 is also valid
342
439
 
343
440
  # get interpolated sample from self.audioData
344
441
  currentSampleArray = np.zeros(self.numChannels, dtype=np.float32)
345
- if self.numChannels == 1: # mono audio source
346
- sampleValue1 = self.audioData[readPosInt1]
347
- # handle the case where audioData might only have one frame. If so, sampleValue2 is the same as sampleValue1.
348
- sampleValue2 = self.audioData[readPosInt2 if self.numFrames > 1 else readPosInt1] # avoid reading past if only 1 frame
349
- # linear interpolation: V = V1 + (V2 - V1) * fraction
350
- currentSampleArray[0] = sampleValue1 + (sampleValue2 - sampleValue1) * fraction
442
+
443
+ if self.numChannels == 1: # mono audio source
444
+ sampleValue1 = self.audioData[readPosInt1] # first sample value
445
+
446
+ # if there is only one frame, use sampleValue1 for both; otherwise, get the next sample
447
+ if self.numFrames > 1:
448
+ sampleValue2 = self.audioData[readPosInt2] # second sample value
449
+
450
+ else:
451
+ sampleValue2 = sampleValue1 # only one frame, so repeat
452
+
453
+ #### Perform linear interpolation between the two sample values.
454
+ # NOTE: This is necessary because playback speed (rateFactor) may not be exactly 1.0,
455
+ # so the read position can fall between two discrete audio samples.
456
+ # Linear interpolation estimates the sample value at this fractional position,
457
+ # resulting in smoother pitch shifting and time stretching, and avoids artifacts
458
+ # that would occur if we simply rounded to the nearest sample.
459
+ interpolatedValue = sampleValue1 + (sampleValue2 - sampleValue1) * fraction
460
+ currentSampleArray[0] = interpolatedValue # store result in output array
461
+
351
462
  else: # stereo source
352
463
  # for stereo, we interpolate each channel (Left and Right) independently.
353
- sampleValue1_L = self.audioData[readPosInt1, 0] # Left channel, first sample
354
- sampleValue2_L = self.audioData[readPosInt2 if self.numFrames > 1 else readPosInt1, 0] # Left channel, second sample
355
- currentSampleArray[0] = sampleValue1_L + (sampleValue2_L - sampleValue1_L) * fraction # Interpolated Left channel
464
+ sampleValue1_L = self.audioData[readPosInt1, 0] # Left channel, first sample
465
+ sampleValue2_L = self.audioData[readPosInt2 if self.numFrames > 1 else readPosInt1, 0] # left channel, second sample
466
+ currentSampleArray[0] = sampleValue1_L + (sampleValue2_L - sampleValue1_L) * fraction # interpolated left channel
356
467
 
357
468
  sampleValue1_R = self.audioData[readPosInt1, 1] # right channel, first sample
358
- sampleValue2_R = self.audioData[readPosInt2 if self.numFrames > 1 else readPosInt1, 1] # Right channel, second sample
359
- currentSampleArray[1] = sampleValue1_R + (sampleValue2_R - sampleValue1_R) * fraction # Interpolated Right channel
469
+ sampleValue2_R = self.audioData[readPosInt2 if self.numFrames > 1 else readPosInt1, 1] # right channel, second sample
470
+ currentSampleArray[1] = sampleValue1_R + (sampleValue2_R - sampleValue1_R) * fraction # interpolated right channel
360
471
 
361
- # --- Apply Volume --- (Volume is applied first before fades)
472
+ #### Apply Volume (Volume is applied first before fades)
362
473
  # the overall volume of the sample is scaled by self.volumeFactor
363
474
  processedSample = currentSampleArray * self.volumeFactor
364
475
 
365
- # --- Apply Master Fades (Fade-in and Fade-out) ---
476
+ #### Apply Master Fades (Fade-in and Fade-out) ---
366
477
  # Fades are applied by smoothly changing a gain envelope from 0 to 1 (fade-in)
367
478
  # or 1 to 0 (fade-out) over a specified number of frames.
368
479
  gainEnvelope = 1.0 # start with full gain, adjust if fading.
369
- if self.isApplyingFadeIn:
370
- if self.fadeInFramesProcessed < self.fadeInTotalFrames:
480
+
481
+ if self.isApplyingFadeIn: # is fade-in currently being applied?
482
+
483
+ if self.fadeInFramesProcessed < self.fadeInTotalFrames: # check if fade-in is still in progress
371
484
  # Calculate gain based on how many fade-in frames have been processed.
372
485
  # This creates a linear ramp from 0.0 to 1.0.
373
- gainEnvelope *= (self.fadeInFramesProcessed / self.fadeInTotalFrames) # ramp from 0 to 1
374
- self.fadeInFramesProcessed += 1
375
- else: # fade-in complete
376
- self.isApplyingFadeIn = False # Stop applying fade-in for subsequent samples.
486
+ gainEnvelope *= (self.fadeInFramesProcessed / self.fadeInTotalFrames) # ramp from 0 to 1
487
+ self.fadeInFramesProcessed += 1 # increment frame
488
+
489
+ else: # fade-in complete
490
+ self.isApplyingFadeIn = False # stop applying fade-in for subsequent samples.
377
491
  # gainEnvelope is already 1.0, fadeInFramesProcessed is capped by play() or setter
378
492
 
379
- if self.isApplyingFadeOut:
380
- if self.fadeOutFramesProcessed < self.fadeOutTotalFrames:
493
+ if self.isApplyingFadeOut: # is fade-out currently being applied?
494
+
495
+ if self.fadeOutFramesProcessed < self.fadeOutTotalFrames: # is fade-out still in progress?
381
496
  currentFadeOutProgress = self.fadeOutFramesProcessed / self.fadeOutTotalFrames
497
+
382
498
  # Calculate gain based on how many fade-out frames have been processed.
383
499
  # This creates a linear ramp from 1.0 down to 0.0.
384
500
  gainEnvelope *= (1.0 - currentFadeOutProgress) # ramp from 1 to 0
385
501
  self.fadeOutFramesProcessed += 1
386
- else: # fade-out complete
502
+
503
+ else: # fade-out complete
387
504
  gainEnvelope = 0.0 # ensure silence
388
505
  self.isApplyingFadeOut = False # stop applying fade-out.
389
- if self.isFadingOutToStop:
506
+
507
+ if self.isFadingOutToStop: # was fade-out triggered by a stop request?
390
508
  # If this fade-out was intended to stop playback (e.g., user called stop()),
391
509
  # set isPlaying to False. This will be caught at the start of the next
392
510
  # sample processing iteration or at the end of this audio block.
393
- self.isPlaying = False # this will be caught at start of next sample or end of block
511
+ self.isPlaying = False # this will be caught at start of next sample or end of block
394
512
  self.isFadingOutToStop = False
395
- self.targetEndSourceFrame = -1.0 # reset here
396
- self.playDurationSourceFrames = -1.0 # reset here
513
+ self.targetEndSourceFrame = -1.0 # reset
514
+ self.playDurationSourceFrames = -1.0 # reset
397
515
 
398
- processedSample *= gainEnvelope # apply the combined fade gain to the sample.
516
+ processedSample = processedSample * gainEnvelope # apply the combined fade gain to the sample
399
517
 
400
- # --- Apply Panning --- (To the already faded and volume-adjusted sample)
518
+ #### Apply Panning (to the already faded and volume-adjusted sample)
401
519
  finalOutputSample = np.zeros(numOutputChannels, dtype=np.float32)
520
+
402
521
  if numOutputChannels == 2: # stream is stereo
403
522
  panValue = self.currentPanFactor # use smoothed value updated at end of block
404
- # Standard psychoacoustic panning law (constant power)
405
- # This formula ensures that the total perceived loudness remains relatively constant
523
+
524
+ #### Standard psychoacoustic panning law (constant power)
525
+ # NOTE: This formula ensures that the total perceived loudness remains relatively constant
406
526
  # as the sound is panned from left to right.
407
527
  # pan value from -1 (L) to 0 (C) to 1 (R)
408
528
  # angle goes from 0 (L) to PI/4 (C) to PI/2 (R)
@@ -414,24 +534,29 @@ class _RealtimeAudioPlayer:
414
534
  # apply panning gains to the single source channel for stereo output
415
535
  finalOutputSample[0] = processedSample[0] * leftGain
416
536
  finalOutputSample[1] = processedSample[0] * rightGain
537
+
417
538
  else: # stereo source to stereo output
418
539
  # Apply panning gains to each respective channel of the stereo source.
419
- # Note: This is a simple pan of a stereo source. More sophisticated stereo
540
+ # NOTE: This is a simple pan of a stereo source. More sophisticated stereo
420
541
  # panners might treat the channels differently (e.g., balance control).
421
542
  finalOutputSample[0] = processedSample[0] * leftGain
422
543
  finalOutputSample[1] = processedSample[1] * rightGain
544
+
423
545
  else: # stream is mono
546
+
424
547
  if self.numChannels == 1: # mono source to mono output
425
548
  # no panning needed, just pass the sample through.
426
549
  finalOutputSample[0] = processedSample[0]
550
+
427
551
  else: # stereo source to mono output (mix down)
428
552
  # Mix the left and right channels of the stereo source to a single mono channel.
429
553
  # The 0.5 factor helps prevent clipping when combining channels.
430
- finalOutputSample[0] = (processedSample[0] + processedSample[1]) * 0.5 # mixdown with gain to avoid clipping
554
+ finalOutputSample[0] = (processedSample[0] + processedSample[1]) * 0.5 # mixdown with gain to avoid clipping
431
555
 
432
- chunkBuffer[i] = finalOutputSample
556
+ # now, the output sample is processed
557
+ chunkBuffer[i] = finalOutputSample # store the processed output sample in the chunk buffer
433
558
 
434
- # --- Advance Playback Position for next sample ---
559
+ #### Advance Playback Position for next sample
435
560
  # self.playbackPosition is advanced by self.rateFactor. If rateFactor is 1.0, it moves one sample forward.
436
561
  # If rateFactor is 0.5, it effectively plays at half speed (each source sample is held for two output samples, due to interpolation).
437
562
  # If rateFactor is 2.0, it plays at double speed (skipping source samples, with interpolation filling the gaps).
@@ -444,21 +569,25 @@ class _RealtimeAudioPlayer:
444
569
  if self.looping and self.loopRegionEndFrame > 0:
445
570
  # if looping and a specific loop region end is defined, that's our segment end
446
571
  effectiveSegmentEndFrame = self.loopRegionEndFrame
572
+
447
573
  elif not self.looping and self.targetEndSourceFrame > 0: # play(size) scenario
448
574
  # if not looping, but a specific duration was given (play(size)), that defines the segment end
449
575
  effectiveSegmentEndFrame = self.targetEndSourceFrame
450
576
 
451
577
  # check for end of segment (loop iteration, play(size) duration, or natural EOF)
452
578
  if self.playbackPosition >= effectiveSegmentEndFrame:
579
+
453
580
  # we've reached or passed the end of the current audio segment
454
581
  if self.looping:
455
582
  self.loopsPerformed += 1
583
+
456
584
  if self.loopCountTarget != -1 and self.loopsPerformed >= self.loopCountTarget:
457
585
  # If we've reached the target number of loops (and it's not infinite looping),
458
586
  # stop playback.
459
587
  self.isPlaying = False
460
588
  self.loopsPerformed = 0 # reset for next play call
461
589
  # other loop params (loopCountTarget, loopRegionStartFrame, loopRegionEndFrame) are reset by play()
590
+
462
591
  else: # continue looping (either infinite or more loops to go)
463
592
  # wrap position back to the start of the loop region
464
593
  # This causes playback to jump back to self.loopRegionStartFrame to continue the loop.
@@ -467,6 +596,7 @@ class _RealtimeAudioPlayer:
467
596
  else: # not looping, and reached end of specified segment or natural EOF
468
597
  # this handles both play(size) completion and natural end of a non-looping file.
469
598
  self.isPlaying = False # stop playback.
599
+
470
600
  if self.playbackPosition >= self.numFrames -1: # check if it was natural EOF
471
601
  # If we've also reached or passed the actual end of the audio file data,
472
602
  # mark that playback ended naturally.
@@ -478,58 +608,72 @@ class _RealtimeAudioPlayer:
478
608
  # loop counters are reset by play() or if explicitly stopped.
479
609
  # self.loopsPerformed = 0 # reset here too just in case.
480
610
 
481
- # --- Post-loop/end-of-segment logic, check if isPlaying is still true before interpolation ---
482
- # This check is crucial. If the logic above (loop completion, segment end) set isPlaying to False,
611
+ #### Post-loop/end-of-segment logic, check if isPlaying is still true before interpolation
612
+ # NOTE: This check is crucial. If the logic above (loop completion, segment end) set isPlaying to False,
483
613
  # we need to fill the rest of the current audio chunk with silence and exit the sample loop.
484
614
  if not self.isPlaying:
485
615
  chunkBuffer[i:] = 0.0 # fill remaining part of this chunk with silence
486
616
  break # exit per-sample loop
617
+ # TODO: rewrite to avoid break
487
618
 
488
- # --- End of per-sample loop (for i in range(frames)) ---
619
+ #### End of per-sample loop (for i in range(frames))
489
620
 
490
- # --- Update smoothed pan factor (block-level, after all samples in this chunk are processed) ---
491
- # To avoid abrupt changes in panning, which can sound like clicks or pops,
621
+ #### Update smoothed pan factor (block-level, after all samples in this chunk are processed)
622
+ # NOTE: To avoid abrupt changes in panning, which can sound like clicks or pops,
492
623
  # we smoothly transition the self.currentPanFactor towards self.panTargetFactor over
493
624
  # a short duration (self.panSmoothingTotalFrames).
494
- # This calculation is done once per audio block (callback invocation) rather than per sample
495
- # for efficiency, and because per-sample smoothing might be overkill for panning.
625
+ # This calculation is done once per audio block rather than per sample
626
+ # for efficiency, and because per-sample smoothing would be overkill.
627
+ # check if pan smoothing is still in progress for this block
496
628
  if self.panSmoothingFramesProcessed < self.panSmoothingTotalFrames:
497
- self.panSmoothingFramesProcessed += frames # accumulate frames processed in this callback
629
+ self.panSmoothingFramesProcessed += frames # accumulate frames processed in this callback
630
+
631
+ # check if smoothing has now reached or exceeded the total smoothing duration
498
632
  if self.panSmoothingFramesProcessed >= self.panSmoothingTotalFrames:
499
- self.currentPanFactor = self.panTargetFactor # target reached, snap to it.
500
- self.panSmoothingFramesProcessed = self.panSmoothingTotalFrames # cap it
633
+ self.currentPanFactor = self.panTargetFactor # target reached, snap to it
634
+ self.panSmoothingFramesProcessed = self.panSmoothingTotalFrames # cap it
635
+
501
636
  else:
502
- # interpolate current pan factor for the block based on progress
503
- # t is the fraction of the smoothing duration that has passed.
637
+ # smoothing is still in progress, so interpolate current pan factor for the block
638
+ # t is the fraction of the smoothing duration that has passed
504
639
  t = self.panSmoothingFramesProcessed / self.panSmoothingTotalFrames
505
640
  self.currentPanFactor = self.panInitialFactor + (self.panTargetFactor - self.panInitialFactor) * t
506
- else: # smoothing is complete or wasn't active for this block duration
507
- self.currentPanFactor = self.panTargetFactor # ensure it's at target if smoothing just finished or was already done
508
641
 
509
- # --- Copy the fully processed chunkBuffer to the output buffer for sounddevice ---
510
- # Audio samples should typically be within the range -1.0 to 1.0.
642
+ else: # smoothing is complete or wasn't active for this block duration
643
+ self.currentPanFactor = self.panTargetFactor # ensure it's at target if smoothing just finished or was already done
644
+
645
+ #### Copy the fully processed chunkBuffer to the output buffer for sounddevice
646
+ # NOTE: Audio samples should typically be within the range -1.0 to 1.0.
511
647
  # np.clip ensures that any values outside this range (due to processing, bugs, or loud source material)
512
648
  # are clamped to the min/max, preventing potential distortion or errors in the audio output driver.
513
649
  outdata[:] = np.clip(chunkBuffer, -1.0, 1.0)
514
650
 
515
- # --- Handle stream stopping conditions ---
516
- # If isPlaying became False during this callback (e.g., by fade-out completion or natural end)
651
+ #### Handle stream stopping conditions
652
+ # NOTE: If isPlaying became False during this callback (e.g., by fade-out completion or natural end)
517
653
  # raise CallbackStop to tell PortAudio to stop invoking this callback.
518
654
  # This is the primary mechanism for cleanly stopping the audio stream from within the callback
519
655
  # when playback is logically complete or has been requested to stop.
520
656
  if not self.isPlaying:
521
657
  raise sd.CallbackStop
522
658
 
659
+ #### end of audioCallback
660
+
661
+
523
662
  def play(self, startAtBeginning=True, loop=None, playDurationSourceFrames=-1.0,
524
663
  loopRegionStartFrame=0.0, loopRegionEndFrame=-1.0, loopCountTarget=None,
525
664
  initialLoopsPerformed=0):
526
665
 
666
+ # does audio file contain zero frames?
527
667
  if self.numFrames == 0:
668
+ # if so, do not attempt to play
528
669
  print(f"Cannot play '{os.path.basename(self.filepath)}' as it contains zero audio frames.")
529
- self.isPlaying = False # ensure state consistency
530
- return # do not proceed to start a stream
670
+ self.isPlaying = False # ensure state consistency
671
+ return # do not proceed to start a stream
672
+ # TODO: rewrite to avoid early return
531
673
 
674
+ # is a fade-out to stop currently in progress?
532
675
  if self.isFadingOutToStop:
676
+ # if so, reset fade-out state before playing
533
677
  self.isApplyingFadeOut = False
534
678
  self.isFadingOutToStop = False
535
679
  self.fadeOutFramesProcessed = 0
@@ -537,65 +681,80 @@ class _RealtimeAudioPlayer:
537
681
  # determine definitive looping state and count from parameters
538
682
  if loop is not None:
539
683
  self.looping = bool(loop)
684
+
540
685
  elif loopCountTarget is not None: # loop is None, derive from loopCountTarget
541
686
  self.looping = True if loopCountTarget != 0 else False
542
- # If both loop and loopCountTarget are None, self.looping is unchanged (relevant if stream is already playing)
687
+
688
+ # Now, if both loop and loopCountTarget are None, self.looping is unchanged (relevant if stream is already playing)
543
689
  # or takes its initial value from __init__ if stream is being started for the first time.
544
690
 
691
+ # determine how many times to loop (or not) based on input and current looping state
545
692
  if loopCountTarget is not None:
546
- self.loopCountTarget = loopCountTarget
547
- elif self.looping: # loopCountTarget is None, but self.looping is True (e.g. from init or previous call)
548
- self.loopCountTarget = -1 # default to infinite if not specified but looping is true
549
- else: # loopCountTarget is None and self.looping is False
550
- self.loopCountTarget = 0 # default to play once
693
+ self.loopCountTarget = loopCountTarget # use the provided loop count
694
+
695
+ elif self.looping: # no explicit loop count, but looping is enabled
696
+ self.loopCountTarget = -1 # default to infinite looping
697
+
698
+ else: # not looping and no loop count specified
699
+ self.loopCountTarget = 0 # play once by default
551
700
 
552
701
  # ensure consistency: if loopCountTarget implies a certain looping state, it can refine self.looping
553
702
  if self.loopCountTarget == 0: # explicitly play once for this segment
554
703
  self.looping = False
704
+
555
705
  elif self.loopCountTarget == -1 or self.loopCountTarget > 0: # infinite or positive count implies looping
556
706
  self.looping = True
557
707
 
558
708
  # store parameters
559
- self.playDurationSourceFrames = playDurationSourceFrames # used if not self.looping
709
+ self.playDurationSourceFrames = playDurationSourceFrames # used if not self.looping
560
710
  self.loopRegionStartFrame = max(0.0, float(loopRegionStartFrame))
561
711
  self.loopRegionEndFrame = float(loopRegionEndFrame) if loopRegionEndFrame is not None else -1.0
562
712
 
563
- # validate loopRegionEndFrame against numFrames if it's positive
713
+ # adjust the loop region end frame so it does not exceed the last valid frame in the audio file
564
714
  if self.loopRegionEndFrame > 0:
565
715
  self.loopRegionEndFrame = min(self.loopRegionEndFrame, self.numFrames - 1 if self.numFrames > 0 else 0.0)
566
- # ensure start frame is not after end frame if both are positive and numFrames > 0
716
+
717
+ # ensure the loop region is valid: if the start frame is after or equal to the end frame, reset to default (full file)
567
718
  if self.numFrames > 0 and self.loopRegionEndFrame > 0 and self.loopRegionStartFrame >= self.loopRegionEndFrame:
568
719
  self.loopRegionStartFrame = 0.0
569
- self.loopRegionEndFrame = -1.0 # default to full loop
570
-
720
+ self.loopRegionEndFrame = -1.0 # default to looping the entire file
571
721
 
722
+ # determine the stopping point for playback based on looping and duration settings
572
723
  if self.looping:
573
- # if looping, playDurationSourceFrames (for play(size)) is ignored in favor of loop settings.
724
+ # when looping, ignore playDurationSourceFrames and play until loop count is reached or forever
574
725
  self.targetEndSourceFrame = -1.0
575
- elif self.playDurationSourceFrames >= 0: # not looping, but play(size) is active (or 0 duration)
726
+
727
+ elif self.playDurationSourceFrames >= 0:
728
+ # when not looping but a specific play duration is requested, calculate the end frame for playback
576
729
  currentStartForCalc = self.playbackPosition if not startAtBeginning else self.loopRegionStartFrame
577
730
  self.targetEndSourceFrame = currentStartForCalc + self.playDurationSourceFrames
578
- # self.loopCountTarget is already 0 if self.looping is false due to above logic.
579
- else: # not looping, no specific duration (play once through, natural EOF)
731
+ # self.loopCountTarget is already 0 if not looping
732
+
733
+ else:
734
+ # when not looping and no duration is specified, play until the natural end of the file
580
735
  self.targetEndSourceFrame = -1.0
581
- # self.loopCountTarget is already 0.
736
+ # self.loopCountTarget is already 0
582
737
 
583
738
  # handle playback position and loops performed count based on startAtBeginning and initialLoopsPerformed
584
739
  if startAtBeginning:
585
740
  self.playbackPosition = self.loopRegionStartFrame # start at the beginning of the loop region (or 0 if not specified)
586
741
  self.loopsPerformed = initialLoopsPerformed # typically 0 for a fresh start from beginning
742
+
587
743
  else: # resuming (startAtBeginning=False)
588
744
  # playbackPosition is where it was left off by pause or setCurrentTime
589
- self.loopsPerformed = initialLoopsPerformed # restore from argument
745
+ self.loopsPerformed = initialLoopsPerformed # restore from argument
590
746
 
591
- self.playbackEndedNaturally = False # reset this flag as we are starting/resuming a play action
747
+ self.playbackEndedNaturally = False # reset this flag as we are starting/resuming a play action
748
+
749
+ # if already playing, these settings will take effect, but stream isn't restarted
592
750
 
593
- # if already playing, these settings will take effect, but stream isn't restarted.
594
751
  # if not playing, start the stream.
595
752
  if not self.isPlaying:
753
+
596
754
  if self.playbackPosition >= self.numFrames and not self.looping:
597
755
  self.playbackPosition = 0.0 # or self._findNextZeroCrossing(0.0)
598
- self.playbackEndedNaturally = False
756
+
757
+ self.playbackEndedNaturally = False # playback did not reach the end
599
758
 
600
759
  try:
601
760
  # always start with a fade-in when initiating play from a stopped state or from a fade-out-to-stop state
@@ -603,12 +762,12 @@ class _RealtimeAudioPlayer:
603
762
  self.fadeInFramesProcessed = 0
604
763
 
605
764
  if self.sdStream and not self.sdStream.closed:
606
- # If stream exists but was stopped (e.g. by isPlaying = False in callback)
765
+ # NOTE: If stream exists but was stopped (e.g. by isPlaying = False in callback)
607
766
  # It needs to be closed and reopened, or sounddevice's start() might not work as expected
608
767
  # or might resume from an odd state. Safest is to ensure clean start.
609
768
  self.sdStream.close() # ensure previous instance is closed
610
769
 
611
-
770
+ # create the sounddevice output stream for playback
612
771
  self.sdStream = sd.OutputStream(
613
772
  samplerate=self.sampleRate,
614
773
  blocksize=self.chunkSize,
@@ -616,115 +775,170 @@ class _RealtimeAudioPlayer:
616
775
  callback=self.audioCallback,
617
776
  finished_callback=self.onPlaybackFinished
618
777
  )
778
+
779
+ # start the sounddevice output stream
619
780
  self.sdStream.start()
620
781
  self.isPlaying = True
621
- self.playbackEndedNaturally = False # reset this flag as we are starting
622
- except Exception as e:
782
+ self.playbackEndedNaturally = False # reset this flag as we are starting
783
+
784
+ except Exception as e: # sounddevice stream did not start
623
785
  print(f"Error starting sounddevice stream: {e}")
624
786
  self.isPlaying = False
787
+
625
788
  if self.sdStream:
626
789
  self.sdStream.close() # ensure cleanup if start failed
627
790
  self.sdStream = None
791
+
628
792
  # do not re-raise here, allow AudioSample to handle or log
629
- return # exit if stream failed to start
793
+ return # exit since stream failed to start
794
+ # TODO: rewrite to avoid early return
630
795
 
631
- if startAtBeginning and self.isPlaying: # check isPlaying again in case it was set by new stream
796
+ if startAtBeginning and self.isPlaying: # check isPlaying again in case it was set by new stream
632
797
  self.isApplyingFadeIn = True
633
798
  self.fadeInFramesProcessed = 0
634
799
 
800
+
635
801
  def onPlaybackFinished(self):
636
- # This callback is called when the stream is stopped or aborted.
802
+ # NOTE: This callback is called when the stream is stopped or aborted.
637
803
  # self.isPlaying = False # Stream is already stopped, this reflects state
638
804
  # Don't reset looping here, it's a persistent setting.
639
805
  # Don't reset playbackPosition here, might be needed for resume or query.
806
+
807
+ # close the sounddevice stream if it exists and is not already closed
640
808
  if self.sdStream and not self.sdStream.closed:
641
- try: # add try-except for robustness during atexit
642
- self.sdStream.close() # ensure it's closed
809
+
810
+ try: # handle possible exceptions during stream closure (e.g., at interpreter exit)
811
+ self.sdStream.close() # close the stream to release audio resources
812
+
643
813
  except sd.PortAudioError as pae:
814
+
644
815
  if pae.args[1] == sd.PaErrorCode.paNotInitialized:
645
- pass # PortAudio already terminated, expected during atexit
816
+ pass # portaudio already terminated, which is expected during atexit
817
+
646
818
  else:
647
819
  print(f"PortAudioError closing stream: {pae}")
820
+
648
821
  except Exception as e:
649
822
  print(f"Generic error closing PortAudio stream: {e}")
650
823
 
651
- self.sdStream = None # discard stream instance
824
+ self.sdStream = None # clear the stream reference after closing
652
825
 
653
- # If it was a fade out to stop, the isPlaying should already be false.
826
+ # Now, if it was a fade out to stop, the isPlaying should already be false.
654
827
  # If it stopped for other reasons (e.g. error, or CallbackStop raised not due to fade),
655
828
  # ensure isPlaying is False.
656
829
  # However, self.isPlaying is primarily controlled by play() and stop() and callback logic.
657
830
  # this finished_callback is more for resource cleanup.
658
831
 
832
+
659
833
  def getLoopsPerformed(self):
660
- return self.loopsPerformed
834
+ return self.loopsPerformed # number of loops completed
835
+
661
836
 
662
837
  def stop(self, immediate=False):
663
- if not self.isPlaying and not self.isApplyingFadeOut: # if already stopped and not in a pending fade-out
838
+ """
839
+ Stops audio playback with optional immediate termination.
840
+
841
+ This method provides two stopping modes: immediate (instant stop) and gradual
842
+ (fade-out stop). The method handles cleanup of audio streams, resets playback
843
+ state variables, and manages fade transitions appropriately.
844
+ """
845
+
846
+ # handle case where already stopped (but may have pending fade-out)
847
+ if not self.isPlaying and not self.isApplyingFadeOut:
848
+
664
849
  # ensure stream is actually closed if somehow isPlaying is False but stream exists
665
850
  if self.sdStream and not self.sdStream.closed:
851
+
666
852
  try:
667
- self.sdStream.close()
853
+ self.sdStream.close() # close the audio stream
854
+
668
855
  except sd.PortAudioError as pae:
669
- if pae.args[1] == sd.PaErrorCode.paNotInitialized: pass
670
- else: print(f"Error closing PyAudio stream: {pae}")
671
- except Exception as e: print(f"Generic error closing PyAudio stream: {e}")
672
- self.sdStream = None
673
856
 
674
- self.isPlaying = False # confirm state
675
- self.targetEndSourceFrame = -1.0 # ensure reset if stopped externally
676
- self.playDurationSourceFrames = -1.0 # ensure reset
677
- # reset loop attributes on stop
678
- self.loopRegionStartFrame = 0.0
679
- self.loopRegionEndFrame = -1.0
680
- self.loopCountTarget = -1 if self.looping else 0 # reset to reflect constructor state or play-once
681
- self.loopsPerformed = 0
682
- return
857
+ if pae.args[1] == sd.PaErrorCode.paNotInitialized:
858
+ pass # ignore if PortAudio already terminated
859
+
860
+ else:
861
+ print(f"Error closing PyAudio stream: {pae}")
862
+
863
+ except Exception as e:
864
+ print(f"Generic error closing PyAudio stream: {e}")
865
+
866
+ finally:
867
+ self.sdStream = None # clear stream reference
683
868
 
684
- # if we want to skip the nice fade-out for immediacy
869
+ # reset all playback state variables
870
+ self.isPlaying = False # confirm stopped state
871
+ self.targetEndSourceFrame = -1.0 # reset end frame target
872
+ self.playDurationSourceFrames = -1.0 # reset duration tracking
873
+
874
+ # reset loop attributes on stop
875
+ self.loopRegionStartFrame = 0.0 # reset loop start to beginning
876
+ self.loopRegionEndFrame = -1.0 # reset loop end to no loop
877
+ self.loopCountTarget = -1 if self.looping else 0 # reset to constructor state
878
+ self.loopsPerformed = 0 # reset loop counter
879
+ return # done, since already stopped
880
+ # TODO: rewrite without early return
881
+
882
+ # handle immediate stop (skip fade-out for instant termination)
685
883
  if immediate:
686
- self.isPlaying = False # signal callback to stop producing audio
687
- self.isApplyingFadeIn = False # cancel any ongoing fade-in
688
- self.isApplyingFadeOut = False # cancel any ongoing fade-out (e.g. from pause)
689
- self.isFadingOutToStop = False # ensure this is reset
884
+ # immediately signal all playback logic to stop
885
+ self.isPlaying = False # signal callback to stop producing audio
886
+ self.isApplyingFadeIn = False # cancel any ongoing fade-in
887
+ self.isApplyingFadeOut = False # cancel any ongoing fade-out (e.g. from pause)
888
+ self.isFadingOutToStop = False # ensure fade-out-to-stop is reset
690
889
 
890
+ # handle immediate stream termination
691
891
  if self.sdStream:
892
+
692
893
  try:
693
894
  # check if stream is active before trying to stop
895
+
694
896
  if not self.sdStream.stopped:
695
- self.sdStream.stop() # stop the PortAudio stream immediately
897
+ self.sdStream.stop() # stop the PortAudio stream immediately
696
898
 
697
899
  # re-check self.sdStream because .stop() could have called _onPlaybackFinished
698
- # which sets self.sdStream to None.
900
+ # which sets self.sdStream to None
901
+
699
902
  if self.sdStream and not self.sdStream.closed:
700
- self.sdStream.close() # close and release resources
903
+ self.sdStream.close() # close and release resources
904
+
701
905
  except sd.PortAudioError as pae:
906
+
702
907
  if pae.args[1] == sd.PaErrorCode.paNotInitialized:
703
- pass # PortAudio already terminated, expected during atexit or rapid stop/close
908
+ pass # PortAudio already terminated, expected during atexit or rapid stop/close
909
+
704
910
  else:
705
911
  print(f"PortAudioError during immediate stream stop/close: {pae}")
912
+
706
913
  except Exception as e:
707
914
  print(f"Error during immediate stream stop/close: {e}")
708
- finally:
709
- self.sdStream = None # discard stream instance
710
915
 
711
- self.targetEndSourceFrame = -1.0
712
- self.playDurationSourceFrames = -1.0
713
- self.loopRegionStartFrame = 0.0
714
- self.loopRegionEndFrame = -1.0
715
- self.loopCountTarget = -1 if self.looping else 0
716
- self.loopsPerformed = 0
717
-
718
- else: # gradual stop (fade out)
719
- if self.isPlaying or self.isApplyingFadeIn: # only start a fade-out if actually playing or was about to start
720
- self.isApplyingFadeIn = False # stop any fade-in
721
- self.isApplyingFadeOut = True
722
- self.isFadingOutToStop = True # mark that this fade-out is intended to stop playback
723
- self.fadeOutFramesProcessed = 0
916
+ finally:
917
+ self.sdStream = None # discard stream instance
918
+
919
+ # reset all playback state variables for immediate stop
920
+ self.targetEndSourceFrame = -1.0 # reset end frame target
921
+ self.playDurationSourceFrames = -1.0 # reset duration tracking
922
+ self.loopRegionStartFrame = 0.0 # reset loop start to beginning
923
+ self.loopRegionEndFrame = -1.0 # reset loop end to no loop
924
+ self.loopCountTarget = -1 if self.looping else 0 # reset to constructor state
925
+ self.loopsPerformed = 0 # reset loop counter
926
+
927
+ else: # gradual stop (fade out)
928
+
929
+ # only start a fade-out if actually playing or was about to start
930
+ if self.isPlaying or self.isApplyingFadeIn:
931
+ self.isApplyingFadeIn = False # stop any fade-in
932
+ self.isApplyingFadeOut = True # start fade-out process
933
+ self.isFadingOutToStop = True # mark that this fade-out is intended to stop playback
934
+ self.fadeOutFramesProcessed = 0 # reset fade-out progress counter
724
935
  # isPlaying remains true until fade-out completes in the callback
725
936
 
937
+ # now, the sounddevice stream is stopped
938
+
939
+
726
940
  def close(self):
727
- self.isPlaying = False # ensure any playback logic stops
941
+ self.isPlaying = False # ensure any playback logic stops
728
942
 
729
943
  # cancel any pending fades that might try to operate on a closing stream
730
944
  self.isApplyingFadeIn = False
@@ -732,26 +946,29 @@ class _RealtimeAudioPlayer:
732
946
  self.isFadingOutToStop = False
733
947
 
734
948
  if self.sdStream:
949
+
735
950
  try:
736
951
  # check if stream is active before trying to stop
737
- if self.sdStream and not self.sdStream.stopped: # check self.sdStream first
738
- self.sdStream.stop() # stop stream activity
952
+ if not self.sdStream.stopped:
953
+ self.sdStream.stop() # stop stream activity
954
+
955
+ # re-check because .stop() could have called onPlaybackFinished
956
+ if not self.sdStream.closed:
957
+ self.sdStream.close() # release resources
739
958
 
740
- # re-check self.sdStream because .stop() could have called onPlaybackFinished
741
- if self.sdStream and not self.sdStream.closed: # check self.sdStream first
742
- self.sdStream.close() # release resources
743
959
  except sd.PortAudioError as pae:
960
+
744
961
  # if PortAudio is already uninitialized (e.g. during atexit), these calls can fail.
745
- if pae.args[1] == sd.PaErrorCode.paNotInitialized: # paNotInitialized = -10000
746
- pass # suppress error if PortAudio is already down
962
+ if pae.args[1] == sd.PaErrorCode.paNotInitialized: # paNotInitialized = -10000
963
+ pass # suppress error if PortAudio is already down
964
+
747
965
  else:
748
966
  print(f"PortAudioError during stream stop/close: {pae}")
749
- # raise # Optionally re-raise if it's a different PortAudio error
750
- print(f"Generic error during PortAudio stream stop/close: {pae}")
751
- # raise # Optionally re-raise
967
+ # raise... # optionally re-raise if it's a different PortAudio error
752
968
  finally:
753
969
  self.sdStream = None
754
970
 
971
+
755
972
  def __del__(self):
756
973
  # call close() to ensure resources are released
757
974
  self.close()