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 +501 -284
- creativepython-0.0.3.dist-info/METADATA +154 -0
- creativepython-0.0.3.dist-info/RECORD +18 -0
- {creativepython-0.0.2.dist-info → creativepython-0.0.3.dist-info}/top_level.txt +3 -1
- creativepython_setup.py +1 -1
- iannix.py +383 -0
- markov.py +263 -0
- music.py +1066 -29
- zipf.py +232 -0
- AudioSample.py +0 -969
- creativepython-0.0.2.dist-info/METADATA +0 -65
- creativepython-0.0.2.dist-info/RECORD +0 -16
- {creativepython-0.0.2.dist-info → creativepython-0.0.3.dist-info}/WHEEL +0 -0
- {creativepython-0.0.2.dist-info → creativepython-0.0.3.dist-info}/entry_points.txt +0 -0
- {creativepython-0.0.2.dist-info → creativepython-0.0.3.dist-info}/licenses/LICENSE +0 -0
_RealtimeAudioPlayer.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#######################################################################################
|
|
2
|
-
#
|
|
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
|
-
|
|
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.
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
122
|
+
# initialize pitch and frequency attributes
|
|
75
123
|
validPitchProvided = False
|
|
76
124
|
if isinstance(actualPitch, (int, float)):
|
|
77
125
|
tempPitch = float(actualPitch)
|
|
78
|
-
|
|
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
|
-
#
|
|
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
|
|
89
|
-
self.baseFrequency = noteToFreq(self.basePitch)
|
|
90
|
-
|
|
91
|
-
#
|
|
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
|
-
|
|
98
|
-
self.
|
|
99
|
-
self.
|
|
100
|
-
self.
|
|
101
|
-
self.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
self.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
self.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
self.
|
|
114
|
-
self.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
self.
|
|
119
|
-
self.
|
|
120
|
-
self.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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[
|
|
150
|
-
if
|
|
151
|
-
nextSample = self.audioData[
|
|
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(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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(
|
|
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(
|
|
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
|
-
#
|
|
166
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
|
235
|
-
return self.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
|
334
|
-
readPosInt1 = int(math.floor(readPosFloat))
|
|
335
|
-
readPosInt2 = readPosInt1 + 1
|
|
336
|
-
fraction = readPosFloat - readPosInt1
|
|
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))
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
#
|
|
350
|
-
|
|
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]
|
|
354
|
-
sampleValue2_L = self.audioData[readPosInt2 if self.numFrames > 1 else readPosInt1, 0]
|
|
355
|
-
currentSampleArray[0] = sampleValue1_L + (sampleValue2_L - sampleValue1_L) * fraction
|
|
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]
|
|
359
|
-
currentSampleArray[1] = sampleValue1_R + (sampleValue2_R - sampleValue1_R) * fraction
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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)
|
|
374
|
-
self.fadeInFramesProcessed += 1
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
502
|
+
|
|
503
|
+
else: # fade-out complete
|
|
387
504
|
gainEnvelope = 0.0 # ensure silence
|
|
388
505
|
self.isApplyingFadeOut = False # stop applying fade-out.
|
|
389
|
-
|
|
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
|
|
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
|
|
396
|
-
self.playDurationSourceFrames = -1.0
|
|
513
|
+
self.targetEndSourceFrame = -1.0 # reset
|
|
514
|
+
self.playDurationSourceFrames = -1.0 # reset
|
|
397
515
|
|
|
398
|
-
processedSample
|
|
516
|
+
processedSample = processedSample * gainEnvelope # apply the combined fade gain to the sample
|
|
399
517
|
|
|
400
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
#
|
|
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
|
|
554
|
+
finalOutputSample[0] = (processedSample[0] + processedSample[1]) * 0.5 # mixdown with gain to avoid clipping
|
|
431
555
|
|
|
432
|
-
|
|
556
|
+
# now, the output sample is processed
|
|
557
|
+
chunkBuffer[i] = finalOutputSample # store the processed output sample in the chunk buffer
|
|
433
558
|
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
619
|
+
#### End of per-sample loop (for i in range(frames))
|
|
489
620
|
|
|
490
|
-
|
|
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
|
|
495
|
-
# for efficiency, and because per-sample smoothing
|
|
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
|
|
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
|
|
500
|
-
self.panSmoothingFramesProcessed = self.panSmoothingTotalFrames
|
|
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
|
|
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
|
-
#
|
|
510
|
-
|
|
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
|
-
|
|
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
|
|
530
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
724
|
+
# when looping, ignore playDurationSourceFrames and play until loop count is reached or forever
|
|
574
725
|
self.targetEndSourceFrame = -1.0
|
|
575
|
-
|
|
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
|
|
579
|
-
|
|
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
|
|
745
|
+
self.loopsPerformed = initialLoopsPerformed # restore from argument
|
|
590
746
|
|
|
591
|
-
self.playbackEndedNaturally = False
|
|
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
|
-
|
|
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
|
|
622
|
-
|
|
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
|
|
793
|
+
return # exit since stream failed to start
|
|
794
|
+
# TODO: rewrite to avoid early return
|
|
630
795
|
|
|
631
|
-
if startAtBeginning and self.isPlaying:
|
|
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
|
-
|
|
642
|
-
|
|
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
|
|
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
|
|
824
|
+
self.sdStream = None # clear the stream reference after closing
|
|
652
825
|
|
|
653
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
687
|
-
self.
|
|
688
|
-
self.
|
|
689
|
-
self.
|
|
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()
|
|
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()
|
|
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
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
self.
|
|
716
|
-
self.
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
|
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
|
|
738
|
-
self.sdStream.stop()
|
|
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:
|
|
746
|
-
pass
|
|
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 #
|
|
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()
|