CreativePython 0.3.2__py3-none-any.whl → 0.3.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 +668 -666
- _notationRenderer.py +921 -0
- {creativepython-0.3.2.dist-info → creativepython-0.3.3.dist-info}/METADATA +3 -1
- {creativepython-0.3.2.dist-info → creativepython-0.3.3.dist-info}/RECORD +17 -16
- {creativepython-0.3.2.dist-info → creativepython-0.3.3.dist-info}/top_level.txt +1 -0
- gui.py +1767 -1734
- iannix.py +5 -4
- image.py +75 -38
- markov.py +6 -3
- midi.py +6 -6
- music.py +1006 -756
- osc.py +5 -5
- timer.py +220 -114
- zipf.py +10 -8
- {creativepython-0.3.2.dist-info → creativepython-0.3.3.dist-info}/WHEEL +0 -0
- {creativepython-0.3.2.dist-info → creativepython-0.3.3.dist-info}/licenses/LICENSE +0 -0
- {creativepython-0.3.2.dist-info → creativepython-0.3.3.dist-info}/licenses/LICENSE-PSF +0 -0
_RealtimeAudioPlayer.py
CHANGED
|
@@ -1,33 +1,43 @@
|
|
|
1
|
-
|
|
2
|
-
# _RealtimeAudioPlayer.py Version 1.0
|
|
1
|
+
###############################################################################
|
|
2
|
+
# _RealtimeAudioPlayer.py Version 1.0 07-Nov-2025
|
|
3
3
|
# Trevor Ritchie, Taj Ballinger, and Bill Manaris
|
|
4
4
|
#
|
|
5
|
-
|
|
5
|
+
###############################################################################
|
|
6
6
|
#
|
|
7
7
|
# [LICENSING GOES HERE]
|
|
8
8
|
#
|
|
9
|
-
|
|
9
|
+
###############################################################################
|
|
10
|
+
#
|
|
11
|
+
# REVISIONS:
|
|
12
|
+
#
|
|
13
|
+
#
|
|
10
14
|
# TODO:
|
|
11
|
-
# -
|
|
15
|
+
# -
|
|
12
16
|
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import sounddevice as sd
|
|
16
|
-
import soundfile as sf
|
|
17
|
-
import numpy as np
|
|
18
|
-
import os
|
|
19
|
-
import math
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
###############################################################################
|
|
18
|
+
|
|
19
|
+
import sounddevice as sd # audio playback
|
|
20
|
+
import soundfile as sf # audio file reading
|
|
21
|
+
import numpy as np # array operations
|
|
22
|
+
import os # file path operations
|
|
23
|
+
import math # log calculations in pitch/freq conversions
|
|
24
|
+
|
|
25
|
+
### Audio Data Cache ##########################################################
|
|
26
|
+
# Shared cache for audio file data to avoid loading the same file multiple times.
|
|
27
|
+
# This dramatically reduces memory usage when many AudioSample instances
|
|
28
|
+
# use the same large file.
|
|
29
|
+
# key: absolute file path (string)
|
|
30
|
+
# value: tuple of (audioData numpy array, sampleRate integer)
|
|
31
|
+
_audioDataCache = {}
|
|
32
|
+
|
|
33
|
+
### Conversion Functions ######################################################
|
|
24
34
|
def freqToNote(frequency):
|
|
25
|
-
"""Converts frequency to the closest MIDI note number with pitch bend value
|
|
26
|
-
for finer control. A4 corresponds to the note number 69 (concert pitch
|
|
27
|
-
is set to 440Hz by default). The default pitch bend range is 4 half tones,
|
|
28
|
-
and ranges from -8191 to +8192 (0 means no pitch bend).
|
|
29
35
|
"""
|
|
30
|
-
|
|
36
|
+
Converts frequency to the closest MIDI note number with pitch bend value
|
|
37
|
+
for finer control. A4 corresponds to the note number 69 (concert pitch
|
|
38
|
+
is set to 440Hz by default). The default pitch bend range is 4 half tones,
|
|
39
|
+
and ranges from -8191 to +8192 (0 means no pitch bend).
|
|
40
|
+
"""
|
|
31
41
|
concertPitch = 440.0 # 440Hz
|
|
32
42
|
bendRange = 4 # 4 semitones (2 below, 2 above)
|
|
33
43
|
|
|
@@ -39,64 +49,95 @@ def freqToNote(frequency):
|
|
|
39
49
|
|
|
40
50
|
|
|
41
51
|
def noteToFreq(pitch):
|
|
42
|
-
"""Converts a MIDI pitch to the corresponding frequency. A4 corresponds to the note number 69 (concert pitch
|
|
43
|
-
is set to 440Hz by default).
|
|
44
52
|
"""
|
|
45
|
-
|
|
53
|
+
Converts a MIDI pitch to the corresponding frequency. A4 corresponds
|
|
54
|
+
to the note number 69 (concert pitch is set to 440Hz by default).
|
|
55
|
+
"""
|
|
46
56
|
concertPitch = 440.0 # 440Hz
|
|
47
|
-
|
|
48
57
|
frequency = concertPitch * 2 ** ( (pitch - 69) / 12.0 )
|
|
49
58
|
|
|
50
59
|
return frequency
|
|
51
60
|
|
|
52
|
-
##### Real-Time Audio Player Class ########################################################
|
|
53
61
|
|
|
62
|
+
##### Realtime Audio Player ##################################################
|
|
54
63
|
class _RealtimeAudioPlayer:
|
|
55
64
|
"""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
AudioSample
|
|
61
|
-
|
|
65
|
+
Realtime audio engine for AudioSample polyphonic playback.
|
|
66
|
+
Used by AudioSample in music.py for polyphonic audio playback.
|
|
67
|
+
|
|
68
|
+
This class provides the low-level audio streaming infrastructure that powers
|
|
69
|
+
AudioSample's polyphonic capabilities. AudioSample creates multiple instances
|
|
70
|
+
of this class (one per voice) to enable complex musical compositions.
|
|
71
|
+
|
|
72
|
+
The engine uses vectorized NumPy operations to process entire audio chunks at once
|
|
73
|
+
rather than individual samples, achieving 3-6x speedup over traditional per-sample
|
|
74
|
+
loops. This enables 16+ simultaneous voices with ~40% CPU usage compared to the
|
|
75
|
+
unoptimized version which saturated at 4-8 voices.
|
|
76
|
+
|
|
77
|
+
Optimization techniques:
|
|
78
|
+
- Vectorized processing: process entire chunks with NumPy (10-100x faster)
|
|
79
|
+
- Pre-allocated buffers: eliminate memory allocation in realtime callbacks
|
|
80
|
+
- Time-stretch pitch shifting: vary playback speed with linear interpolation
|
|
81
|
+
- Smooth transitions: fade envelopes prevent clicks when starting/stopping
|
|
82
|
+
- Batch boundary detection: calculate loop points upfront, not per-sample
|
|
83
|
+
|
|
84
|
+
Audio processing pipeline: Setup & Validation → Pitch Shifting → Dynamics
|
|
85
|
+
Processing → Spatial Processing → Boundary Handling
|
|
62
86
|
"""
|
|
63
87
|
|
|
64
88
|
def __init__(self, filepath, loop=False, actualPitch=69, chunkSize=1024):
|
|
65
89
|
"""
|
|
66
|
-
Initialize
|
|
90
|
+
Initialize realtime audio player - prepares audio engine for polyphonic playback.
|
|
91
|
+
|
|
92
|
+
Sets up all components needed for high-performance audio playback including audio
|
|
93
|
+
data loading, pitch/frequency control, panning, fades, and looping. Everything is
|
|
94
|
+
pre-allocated to minimize realtime overhead since the audio callback must be
|
|
95
|
+
extremely fast to avoid dropouts.
|
|
67
96
|
|
|
68
97
|
filepath: path to the audio file to load and play
|
|
69
98
|
loop: whether to loop the audio playback (default: False)
|
|
70
99
|
actualPitch: MIDI pitch (0-127) representing the base frequency of the audio (default: 69 for A4)
|
|
71
100
|
chunkSize: size of audio chunks for realtime processing (default: 1024 frames)
|
|
72
101
|
"""
|
|
102
|
+
#########################################################################
|
|
103
|
+
# PHASE 1: AUDIO FILE LOADING & VALIDATION
|
|
104
|
+
# load audio data into memory and validate format
|
|
105
|
+
#########################################################################
|
|
106
|
+
|
|
107
|
+
# convert to absolute path for consistent cache key (handles relative paths correctly)
|
|
108
|
+
if not os.path.isabs(filepath):
|
|
109
|
+
filepath = os.path.abspath(filepath)
|
|
73
110
|
|
|
74
|
-
# validate that the audio file exists
|
|
75
111
|
if not os.path.isfile(filepath):
|
|
76
112
|
raise ValueError(f"File not found: {filepath}")
|
|
77
113
|
|
|
78
|
-
self.filepath = filepath # store the file path for reference
|
|
114
|
+
self.filepath = filepath # store the absolute file path for reference
|
|
79
115
|
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
116
|
+
# check if audio data is already cached to avoid loading the same file multiple times
|
|
117
|
+
# NOTE: sharing audio data across players saves significant memory when many voices use the same sample
|
|
118
|
+
if filepath in _audioDataCache:
|
|
119
|
+
# use cached audio data and sample rate (safe to share since audioData is read-only)
|
|
120
|
+
self.audioData, self.sampleRate = _audioDataCache[filepath]
|
|
121
|
+
else:
|
|
122
|
+
# load the audio file using soundfile library
|
|
123
|
+
try:
|
|
124
|
+
self.audioData, self.sampleRate = sf.read(filepath, dtype='float32')
|
|
125
|
+
# store in cache for future use (shared across all players using this file)
|
|
126
|
+
_audioDataCache[filepath] = (self.audioData, self.sampleRate)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
print(f"Error loading audio file with soundfile: {e}")
|
|
129
|
+
raise
|
|
87
130
|
|
|
88
|
-
# analyze audio
|
|
131
|
+
# analyze audio format and store channel/frame information
|
|
89
132
|
if self.audioData.ndim == 1:
|
|
90
|
-
self.numChannels = 1 #
|
|
133
|
+
self.numChannels = 1 # mono audio
|
|
91
134
|
self.numFrames = len(self.audioData)
|
|
92
135
|
|
|
93
136
|
elif self.audioData.ndim == 2:
|
|
94
|
-
self.numChannels = self.audioData.shape[1] #
|
|
137
|
+
self.numChannels = self.audioData.shape[1] # sterio audio
|
|
95
138
|
self.numFrames = self.audioData.shape[0]
|
|
96
|
-
|
|
97
|
-
if self.numChannels > 2: # restrict to mono/stereo for current implementation
|
|
139
|
+
if self.numChannels > 2:
|
|
98
140
|
raise ValueError(f"Unsupported number of channels: {self.numChannels}. Max 2 channels supported.")
|
|
99
|
-
|
|
100
141
|
else:
|
|
101
142
|
raise ValueError(f"Unexpected audio data dimensions: {self.audioData.ndim}")
|
|
102
143
|
|
|
@@ -104,22 +145,36 @@ class _RealtimeAudioPlayer:
|
|
|
104
145
|
if self.numFrames == 0:
|
|
105
146
|
print(f"Warning: Audio file '{os.path.basename(self.filepath)}' contains zero audio frames and is unplayable.")
|
|
106
147
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
self.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
self.
|
|
116
|
-
self.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
148
|
+
#########################################################################
|
|
149
|
+
# PHASE 2: PLAYBACK STATE INITIALIZATION
|
|
150
|
+
# set up core playback control variables
|
|
151
|
+
#########################################################################
|
|
152
|
+
|
|
153
|
+
self.isPlaying = False
|
|
154
|
+
self.playbackPosition = 0.0
|
|
155
|
+
self.looping = loop
|
|
156
|
+
self.rateFactor = 1.0
|
|
157
|
+
self.volumeFactor = 1.0
|
|
158
|
+
|
|
159
|
+
#########################################################################
|
|
160
|
+
# PHASE 3: SPATIAL AUDIO (PANNING) INITIALIZATION
|
|
161
|
+
# configure stereo positioning with smooth transitions
|
|
162
|
+
# NOTE: smoothing prevents audible clicks when pan changes occur
|
|
163
|
+
#########################################################################
|
|
164
|
+
|
|
165
|
+
self.panTargetFactor = 0.0
|
|
166
|
+
self.currentPanFactor = 0.0
|
|
167
|
+
self.panInitialFactor = 0.0
|
|
168
|
+
self.panSmoothingDurationMs = 100
|
|
169
|
+
self.panSmoothingTotalFrames = max(1, int(self.sampleRate * (self.panSmoothingDurationMs / 1000.0)))
|
|
170
|
+
self.panSmoothingFramesProcessed = self.panSmoothingTotalFrames
|
|
171
|
+
|
|
172
|
+
#########################################################################
|
|
173
|
+
# PHASE 4: PITCH/FREQUENCY CONTROL INITIALIZATION
|
|
174
|
+
# establish base pitch for pitch-shifting calculations
|
|
175
|
+
# NOTE: pitch shifting is implemented via time-stretch interpolation
|
|
176
|
+
#########################################################################
|
|
177
|
+
|
|
123
178
|
validPitchProvided = False
|
|
124
179
|
if isinstance(actualPitch, (int, float)):
|
|
125
180
|
tempPitch = float(actualPitch)
|
|
@@ -131,555 +186,75 @@ class _RealtimeAudioPlayer:
|
|
|
131
186
|
|
|
132
187
|
if not validPitchProvided:
|
|
133
188
|
# handle invalid pitch values by defaulting to A4 (440Hz)
|
|
134
|
-
print(f"Warning: Invalid or out-of-range actualPitch ({actualPitch}) provided for '{os.path.basename(self.filepath)}'
|
|
189
|
+
print(f"Warning: Invalid or out-of-range actualPitch ({actualPitch}) provided for '{os.path.basename(self.filepath)}'.\
|
|
190
|
+
Expected MIDI pitch (int/float) 0-127. Defaulting to A4 (69 / 440Hz).")
|
|
135
191
|
self.basePitch = 69.0 # default MIDI A4
|
|
136
192
|
self.baseFrequency = noteToFreq(self.basePitch) # default 440 Hz
|
|
137
193
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
#
|
|
145
|
-
self.
|
|
146
|
-
self.
|
|
147
|
-
self.
|
|
148
|
-
|
|
149
|
-
self.
|
|
150
|
-
|
|
151
|
-
#
|
|
152
|
-
self.
|
|
153
|
-
self.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
self.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
#
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
self.sdStream = None
|
|
203
|
-
raise
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def _findNextZeroCrossing(self, startFrameFloat, searchWindowFrames=256):
|
|
207
|
-
"""
|
|
208
|
-
Finds the nearest zero-crossing at or after startFrameFloat.
|
|
209
|
-
Looks within a small window to avoid long searches.
|
|
210
|
-
Returns the frame index (float) of the sample that is at or just after the zero-crossing.
|
|
211
|
-
If no crossing is found within the window, returns the original startFrame, clamped to audio bounds.
|
|
212
|
-
"""
|
|
213
|
-
|
|
214
|
-
startFrame = int(math.floor(startFrameFloat))
|
|
215
|
-
startFrame = max(0, min(startFrame, self.numFrames - 1))
|
|
216
|
-
|
|
217
|
-
# limit search window to prevent going beyond audio data boundaries
|
|
218
|
-
endSearchFrame = min(self.numFrames - 1, startFrame + searchWindowFrames)
|
|
219
|
-
|
|
220
|
-
if startFrame >= self.numFrames -1: # if already at or past the second to last frame
|
|
221
|
-
return float(min(startFrame, self.numFrames -1))
|
|
222
|
-
|
|
223
|
-
# iterate through the audio frames in the search window to find a zero-crossing
|
|
224
|
-
for frameIdx in range(startFrame, endSearchFrame):
|
|
225
|
-
currentSample = 0.0
|
|
226
|
-
nextSample = 0.0
|
|
227
|
-
|
|
228
|
-
if self.numChannels == 1: # mono audio
|
|
229
|
-
currentSample = self.audioData[frameIdx]
|
|
230
|
-
if frameIdx + 1 < self.numFrames:
|
|
231
|
-
nextSample = self.audioData[frameIdx + 1]
|
|
232
|
-
else:
|
|
233
|
-
return float(frameIdx) # reached end
|
|
234
|
-
|
|
235
|
-
elif self.numChannels >= 2: # stereo (or more)
|
|
236
|
-
currentSample = self.audioData[frameIdx, 0]
|
|
237
|
-
if frameIdx + 1 < self.numFrames:
|
|
238
|
-
nextSample = self.audioData[frameIdx + 1, 0]
|
|
239
|
-
else:
|
|
240
|
-
return float(frameIdx) # reached end
|
|
241
|
-
|
|
242
|
-
# is current sample exactly zero? (a zero-crossing point)
|
|
243
|
-
if currentSample == 0.0:
|
|
244
|
-
return float(frameIdx) # return frame index
|
|
245
|
-
|
|
246
|
-
# is there a sign change between currentSample and nextSample?
|
|
247
|
-
# ...which indicates a zero-crossing between these two samples
|
|
248
|
-
if (currentSample > 0 and nextSample <= 0) or \
|
|
249
|
-
(currentSample < 0 and nextSample >= 0):
|
|
250
|
-
# return the frame index just after the crossing (i+1)
|
|
251
|
-
return float(frameIdx + 1) # closest we can get to zero-crossing
|
|
252
|
-
|
|
253
|
-
return float(startFrame) # no crossing found in window
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def setRateFactor(self, factor):
|
|
257
|
-
# check if the provided factor is a number (int or float)
|
|
258
|
-
if isinstance(factor, (int, float)):
|
|
259
|
-
|
|
260
|
-
# if factor is zero or negative, set to a very small positive value to effectively pause playback
|
|
261
|
-
if factor <= 0:
|
|
262
|
-
self.rateFactor = 0.00001 # avoid zero or negative, effectively silent/pause
|
|
263
|
-
|
|
264
|
-
else:
|
|
265
|
-
# otherwise, set the rate factor to the given value (as float)
|
|
266
|
-
self.rateFactor = float(factor)
|
|
267
|
-
# print(f"Set to {self.rateFactor:.4f}")
|
|
268
|
-
|
|
269
|
-
else:
|
|
270
|
-
# if input is not a number, default to 1x speed
|
|
271
|
-
self.rateFactor = 1.0
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def getRateFactor(self):
|
|
275
|
-
return self.rateFactor # return rate factor
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def setVolumeFactor(self, factor):
|
|
279
|
-
if isinstance(factor, (int, float)):
|
|
280
|
-
# valid factor, so set volume
|
|
281
|
-
self.volumeFactor = max(0.0, min(1.0, float(factor)))
|
|
282
|
-
# print(f"Set to {self.volumeFactor:.3f}")
|
|
283
|
-
|
|
284
|
-
else:
|
|
285
|
-
# factor is invalid type, so default to full volume
|
|
286
|
-
self.volumeFactor = 1.0
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def getVolumeFactor(self):
|
|
290
|
-
return self.volumeFactor # return volume factor
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def setPanFactor(self, panFactor):
|
|
294
|
-
# clamp panFactor to a float in [-1.0, 1.0]; if invalid, default to center (0.0)
|
|
295
|
-
if not isinstance(panFactor, (int, float)):
|
|
296
|
-
clampedPanFactor = 0.0 # not a number, so use center
|
|
297
|
-
|
|
298
|
-
else:
|
|
299
|
-
clampedPanFactor = max(-1.0, min(1.0, float(panFactor))) # clamp to valid range
|
|
300
|
-
|
|
301
|
-
# if the new pan target is different enough from the current target, start smoothing ramp
|
|
302
|
-
if abs(self.panTargetFactor - clampedPanFactor) > 0.001: # significant change
|
|
303
|
-
self.panTargetFactor = clampedPanFactor # set new pan target
|
|
304
|
-
self.panInitialFactor = self.currentPanFactor # remember current pan as ramp start
|
|
305
|
-
self.panSmoothingFramesProcessed = 0 # reset smoothing progress
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def getPanFactor(self):
|
|
309
|
-
return self.panTargetFactor # return pan factor
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def setFrequency(self, targetFrequencyHz):
|
|
313
|
-
if isinstance(targetFrequencyHz, (int, float)) and self.baseFrequency > 0:
|
|
314
|
-
|
|
315
|
-
if targetFrequencyHz > 0: # frequency is valid
|
|
316
|
-
newRateFactor = targetFrequencyHz / self.baseFrequency
|
|
317
|
-
self.setRateFactor(newRateFactor)
|
|
318
|
-
|
|
319
|
-
else: # target frequency too small
|
|
320
|
-
self.setRateFactor(0.00001) # avoid zero or negative, effectively silent/pause
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def getFrequency(self):
|
|
324
|
-
# calculate current frequency based on base and rate
|
|
325
|
-
currentFreq = self.baseFrequency * self.rateFactor
|
|
326
|
-
return currentFreq # return current frequency
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def setPitch(self, midiPitch):
|
|
330
|
-
# set the playback pitch by converting midiPitch (0-127) to frequency and updating rate factor
|
|
331
|
-
if (isinstance(midiPitch, (int, float)) and 0 <= midiPitch <= 127):
|
|
332
|
-
targetFrequencyHz = noteToFreq(float(midiPitch)) # convert midi pitch to frequency
|
|
333
|
-
self.setFrequency(targetFrequencyHz) # set playback frequency accordingly
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def getPitch(self):
|
|
337
|
-
currentFreq = self.getFrequency() # get freq
|
|
338
|
-
currentPitch = freqToNote(currentFreq) # convert to pitch
|
|
339
|
-
return currentPitch # return current pitch
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def getBasePitch(self):
|
|
343
|
-
return self.basePitch # original pitch of the sample
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def getBaseFrequency(self):
|
|
347
|
-
return self.baseFrequency # original frequency of the sample
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def getFrameRate(self):
|
|
351
|
-
return self.sampleRate # sample rate of the audio
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def getCurrentTime(self):
|
|
355
|
-
# calculate current time based on position and sample rate
|
|
356
|
-
currentTime = self.playbackPosition / self.sampleRate
|
|
357
|
-
return currentTime # return current time
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def setCurrentTime(self, timeSeconds):
|
|
361
|
-
# check that timeSeconds is a valid non-negative number. if not, default to 0.0
|
|
362
|
-
if not isinstance(timeSeconds, (int, float)) or timeSeconds < 0:
|
|
363
|
-
timeSeconds = 0.0
|
|
364
|
-
|
|
365
|
-
# convert the requested time in seconds to a floating-point frame index
|
|
366
|
-
originalTargetFrameFloat = timeSeconds * self.sampleRate
|
|
367
|
-
|
|
368
|
-
# basic ZC adjustment for now, will be enhanced with fade-seek-fade
|
|
369
|
-
actualTargetFrame = self._findNextZeroCrossing(originalTargetFrameFloat)
|
|
370
|
-
|
|
371
|
-
# if playing and conditions met for smooth seek
|
|
372
|
-
if actualTargetFrame >= self.numFrames and not self.looping:
|
|
373
|
-
# set playback position to the requested frame, or to the end if beyond available frames
|
|
374
|
-
self.playbackPosition = float(self.numFrames -1)
|
|
375
|
-
self.playbackEndedNaturally = True
|
|
376
|
-
|
|
377
|
-
else:
|
|
378
|
-
self.playbackPosition = actualTargetFrame # set playback position to the next zero crossing
|
|
379
|
-
self.playbackEndedNaturally = False # reset natural end flag if jumping
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
### Playback Control Methods ########################################################################
|
|
383
|
-
|
|
384
|
-
def audioCallback(self, outdata, frames, time, status):
|
|
385
|
-
"""
|
|
386
|
-
This is the core audio processing callback.
|
|
387
|
-
It's called by sounddevice when it needs more audio data.
|
|
388
|
-
|
|
389
|
-
Parameters:
|
|
390
|
-
outdata (numpy.ndarray): A NumPy array that this function needs to fill with audio data.
|
|
391
|
-
This is what will be sent to the sound card.
|
|
392
|
-
Its shape is (frames, numOutputChannels).
|
|
393
|
-
frames (int): The number of audio frames (samples per channel) that sounddevice expects
|
|
394
|
-
this function to produce.
|
|
395
|
-
time (sounddevice. beggeback object): Contains various timestamps related to the audio stream.
|
|
396
|
-
`time.currentTime` is the time at the sound card when the first
|
|
397
|
-
sample of `outdata` will be played.
|
|
398
|
-
`time.inputBufferAdcTime` is the capture time of the first input sample (if input stream).
|
|
399
|
-
`time.outputBufferDacTime` is the time the first output sample will be played.
|
|
400
|
-
status (sounddevice.CallbackFlags): Flags indicating if any stream errors (e.g., input overflow,
|
|
401
|
-
output underflow) have occurred. It's good practice to check
|
|
402
|
-
this, though for simplicity in many examples it might be ignored.
|
|
403
|
-
"""
|
|
404
|
-
|
|
405
|
-
# if status:
|
|
406
|
-
# print(f"Status flags: {status}") # keep this commented unless debugging status
|
|
407
|
-
|
|
408
|
-
# failsafe for zero-frame audio, though play() should prevent this stream from starting.
|
|
409
|
-
if self.numFrames == 0:
|
|
410
|
-
outdata.fill(0) # fill the output buffer with silence.
|
|
411
|
-
|
|
412
|
-
if self.isPlaying: # this should ideally not be true if play() did its job
|
|
413
|
-
self.isPlaying = False # ensure playback state is consistent.
|
|
414
|
-
|
|
415
|
-
raise sd.CallbackStop # stop the callback immediately, as there's no audio to play.
|
|
416
|
-
|
|
417
|
-
# If not playing or rate is effectively zero, output silence.
|
|
418
|
-
# This handles cases where playback is paused, explicitly stopped, or the playback rate
|
|
419
|
-
# is so low that it's practically silent. This is a quick way to silence output
|
|
420
|
-
# without needing to go through the whole processing loop.
|
|
421
|
-
if not self.isPlaying or self.rateFactor <= 0.000001:
|
|
422
|
-
outdata.fill(0) # fill the output buffer with silence.
|
|
423
|
-
|
|
424
|
-
if self.isApplyingFadeOut and self.isFadingOutToStop and self.rateFactor <= 0.000001:
|
|
425
|
-
# If a fade-out to stop was in progress and the rate also became zero (e.g. set externally),
|
|
426
|
-
# ensure the player state is fully stopped.
|
|
427
|
-
self.isPlaying = False
|
|
428
|
-
self.isApplyingFadeOut = False
|
|
429
|
-
self.isFadingOutToStop = False
|
|
430
|
-
|
|
431
|
-
return # exit the callback early, providing silence.
|
|
432
|
-
|
|
433
|
-
numOutputChannels = outdata.shape[1] # get number of output channels (1=mono, 2=stereo)
|
|
434
|
-
|
|
435
|
-
# Initialize chunkBuffer matching the output stream's channel count and frame count for this callback.
|
|
436
|
-
# This buffer will be filled with processed audio samples one by one before being copied to `outdata`.
|
|
437
|
-
# Using an intermediate buffer like this is common for clarity and for complex processing steps.
|
|
438
|
-
chunkBuffer = np.zeros((frames, numOutputChannels), dtype=np.float32)
|
|
439
|
-
|
|
440
|
-
for i in range(frames): # per-sample processing loop
|
|
441
|
-
|
|
442
|
-
if not self.isPlaying: # check if stop was called or playback ended within the loop
|
|
443
|
-
|
|
444
|
-
# If isPlaying became false (e.g., due to a fade-out completing and setting isPlaying to False,
|
|
445
|
-
# or an external stop() call), we should fill the rest of this chunk with silence
|
|
446
|
-
# and then break out of this sample-processing loop.
|
|
447
|
-
|
|
448
|
-
chunkBuffer[i:] = 0.0 # fill remaining part of the buffer with silence
|
|
449
|
-
break # exit per-sample loop
|
|
450
|
-
|
|
451
|
-
#### Determine current sample value with interpolation (and hard loop if enabled)
|
|
452
|
-
# To play audio at different speeds (self.rateFactor != 1.0) or for smooth playback,
|
|
453
|
-
# we often need a sample value that lies *between* two actual data points in our audioData.
|
|
454
|
-
# Linear interpolation is a common way to estimate this value.
|
|
455
|
-
readPosFloat = self.playbackPosition # float indicating the conceptual read position
|
|
456
|
-
readPosInt1 = int(math.floor(readPosFloat)) # the integer part, an index to an actual sample
|
|
457
|
-
readPosInt2 = readPosInt1 + 1 # next actual sample index
|
|
458
|
-
fraction = readPosFloat - readPosInt1 # the fractional part, how far between readPosInt1 and readPosInt2 we are.
|
|
459
|
-
|
|
460
|
-
# Clamp read positions to be safe for array access, *after* potential looping adjustment.
|
|
461
|
-
# This ensures that we don't try to read outside the bounds of our audioData array.
|
|
462
|
-
readPosInt1 = max(0, min(readPosInt1, self.numFrames - 1)) # ensure read position 1 is valid
|
|
463
|
-
readPosInt2 = max(0, min(readPosInt2, self.numFrames - 1)) # ensure read position 2 is also valid
|
|
464
|
-
|
|
465
|
-
# get interpolated sample from self.audioData
|
|
466
|
-
currentSampleArray = np.zeros(self.numChannels, dtype=np.float32)
|
|
467
|
-
|
|
468
|
-
if self.numChannels == 1: # mono audio source
|
|
469
|
-
sampleValue1 = self.audioData[readPosInt1] # first sample value
|
|
470
|
-
|
|
471
|
-
# if there is only one frame, use sampleValue1 for both; otherwise, get the next sample
|
|
472
|
-
if self.numFrames > 1:
|
|
473
|
-
sampleValue2 = self.audioData[readPosInt2] # second sample value
|
|
474
|
-
|
|
475
|
-
else:
|
|
476
|
-
sampleValue2 = sampleValue1 # only one frame, so repeat
|
|
477
|
-
|
|
478
|
-
#### Perform linear interpolation between the two sample values.
|
|
479
|
-
# NOTE: This is necessary because playback speed (rateFactor) may not be exactly 1.0,
|
|
480
|
-
# so the read position can fall between two discrete audio samples.
|
|
481
|
-
# Linear interpolation estimates the sample value at this fractional position,
|
|
482
|
-
# resulting in smoother pitch shifting and time stretching, and avoids artifacts
|
|
483
|
-
# that would occur if we simply rounded to the nearest sample.
|
|
484
|
-
interpolatedValue = sampleValue1 + (sampleValue2 - sampleValue1) * fraction
|
|
485
|
-
currentSampleArray[0] = interpolatedValue # store result in output array
|
|
486
|
-
|
|
487
|
-
else: # stereo source
|
|
488
|
-
# for stereo, we interpolate each channel (Left and Right) independently.
|
|
489
|
-
sampleValue1_L = self.audioData[readPosInt1, 0] # Left channel, first sample
|
|
490
|
-
sampleValue2_L = self.audioData[readPosInt2 if self.numFrames > 1 else readPosInt1, 0] # left channel, second sample
|
|
491
|
-
currentSampleArray[0] = sampleValue1_L + (sampleValue2_L - sampleValue1_L) * fraction # interpolated left channel
|
|
492
|
-
|
|
493
|
-
sampleValue1_R = self.audioData[readPosInt1, 1] # right channel, first sample
|
|
494
|
-
sampleValue2_R = self.audioData[readPosInt2 if self.numFrames > 1 else readPosInt1, 1] # right channel, second sample
|
|
495
|
-
currentSampleArray[1] = sampleValue1_R + (sampleValue2_R - sampleValue1_R) * fraction # interpolated right channel
|
|
496
|
-
|
|
497
|
-
#### Apply Volume (Volume is applied first before fades)
|
|
498
|
-
# the overall volume of the sample is scaled by self.volumeFactor
|
|
499
|
-
processedSample = currentSampleArray * self.volumeFactor
|
|
500
|
-
|
|
501
|
-
#### Apply Master Fades (Fade-in and Fade-out)
|
|
502
|
-
# Fades are applied by smoothly changing a gain envelope from 0 to 1 (fade-in)
|
|
503
|
-
# or 1 to 0 (fade-out) over a specified number of frames.
|
|
504
|
-
gainEnvelope = 1.0 # start with full gain, adjust if fading.
|
|
505
|
-
|
|
506
|
-
if self.isApplyingFadeIn: # is fade-in currently being applied?
|
|
507
|
-
|
|
508
|
-
if self.fadeInFramesProcessed < self.fadeInTotalFrames: # check if fade-in is still in progress
|
|
509
|
-
# Calculate gain based on how many fade-in frames have been processed.
|
|
510
|
-
# This creates a linear ramp from 0.0 to 1.0.
|
|
511
|
-
gainEnvelope *= (self.fadeInFramesProcessed / self.fadeInTotalFrames) # ramp from 0 to 1
|
|
512
|
-
self.fadeInFramesProcessed += 1 # increment frame
|
|
513
|
-
|
|
514
|
-
else: # fade-in complete
|
|
515
|
-
self.isApplyingFadeIn = False # stop applying fade-in for subsequent samples.
|
|
516
|
-
# gainEnvelope is already 1.0, fadeInFramesProcessed is capped by play() or setter
|
|
517
|
-
|
|
518
|
-
if self.isApplyingFadeOut: # is fade-out currently being applied?
|
|
519
|
-
|
|
520
|
-
if self.fadeOutFramesProcessed < self.fadeOutTotalFrames: # is fade-out still in progress?
|
|
521
|
-
currentFadeOutProgress = self.fadeOutFramesProcessed / self.fadeOutTotalFrames
|
|
522
|
-
|
|
523
|
-
# Calculate gain based on how many fade-out frames have been processed.
|
|
524
|
-
# This creates a linear ramp from 1.0 down to 0.0.
|
|
525
|
-
gainEnvelope *= (1.0 - currentFadeOutProgress) # ramp from 1 to 0
|
|
526
|
-
self.fadeOutFramesProcessed += 1
|
|
527
|
-
|
|
528
|
-
else: # fade-out complete
|
|
529
|
-
gainEnvelope = 0.0 # ensure silence
|
|
530
|
-
self.isApplyingFadeOut = False # stop applying fade-out.
|
|
531
|
-
|
|
532
|
-
if self.isFadingOutToStop: # was fade-out triggered by a stop request?
|
|
533
|
-
# If this fade-out was intended to stop playback (e.g., user called stop()),
|
|
534
|
-
# set isPlaying to False. This will be caught at the start of the next
|
|
535
|
-
# sample processing iteration or at the end of this audio block.
|
|
536
|
-
self.isPlaying = False # this will be caught at start of next sample or end of block
|
|
537
|
-
self.isFadingOutToStop = False
|
|
538
|
-
self.targetEndSourceFrame = -1.0 # reset
|
|
539
|
-
self.playDurationSourceFrames = -1.0 # reset
|
|
540
|
-
|
|
541
|
-
processedSample = processedSample * gainEnvelope # apply the combined fade gain to the sample
|
|
542
|
-
|
|
543
|
-
#### Apply Panning (to the already faded and volume-adjusted sample)
|
|
544
|
-
finalOutputSample = np.zeros(numOutputChannels, dtype=np.float32)
|
|
545
|
-
|
|
546
|
-
if numOutputChannels == 2: # stream is stereo
|
|
547
|
-
panValue = self.currentPanFactor # use smoothed value updated at end of block
|
|
548
|
-
|
|
549
|
-
#### Standard psychoacoustic panning law (constant power)
|
|
550
|
-
# NOTE: This formula ensures that the total perceived loudness remains relatively constant
|
|
551
|
-
# as the sound is panned from left to right.
|
|
552
|
-
# pan value from -1 (L) to 0 (C) to 1 (R)
|
|
553
|
-
# angle goes from 0 (L) to PI/4 (C) to PI/2 (R)
|
|
554
|
-
panAngleRad = (panValue + 1.0) * math.pi / 4.0 # convert panValue to an angle
|
|
555
|
-
leftGain = math.cos(panAngleRad) # gain for the left channel
|
|
556
|
-
rightGain = math.sin(panAngleRad) # gain for the right channel
|
|
557
|
-
|
|
558
|
-
if self.numChannels == 1: # mono source to stereo output
|
|
559
|
-
# apply panning gains to the single source channel for stereo output
|
|
560
|
-
finalOutputSample[0] = processedSample[0] * leftGain
|
|
561
|
-
finalOutputSample[1] = processedSample[0] * rightGain
|
|
562
|
-
|
|
563
|
-
else: # stereo source to stereo output
|
|
564
|
-
# Apply panning gains to each respective channel of the stereo source.
|
|
565
|
-
# NOTE: This is a simple pan of a stereo source. More sophisticated stereo
|
|
566
|
-
# panners might treat the channels differently (e.g., balance control).
|
|
567
|
-
finalOutputSample[0] = processedSample[0] * leftGain
|
|
568
|
-
finalOutputSample[1] = processedSample[1] * rightGain
|
|
569
|
-
|
|
570
|
-
else: # stream is mono
|
|
571
|
-
|
|
572
|
-
if self.numChannels == 1: # mono source to mono output
|
|
573
|
-
# no panning needed, just pass the sample through.
|
|
574
|
-
finalOutputSample[0] = processedSample[0]
|
|
575
|
-
|
|
576
|
-
else: # stereo source to mono output (mix down)
|
|
577
|
-
# Mix the left and right channels of the stereo source to a single mono channel.
|
|
578
|
-
# The 0.5 factor helps prevent clipping when combining channels.
|
|
579
|
-
finalOutputSample[0] = (processedSample[0] + processedSample[1]) * 0.5 # mixdown with gain to avoid clipping
|
|
580
|
-
|
|
581
|
-
# now, the output sample is processed
|
|
582
|
-
chunkBuffer[i] = finalOutputSample # store the processed output sample in the chunk buffer
|
|
583
|
-
|
|
584
|
-
#### Advance Playback Position for next sample
|
|
585
|
-
# self.playbackPosition is advanced by self.rateFactor. If rateFactor is 1.0, it moves one sample forward.
|
|
586
|
-
# If rateFactor is 0.5, it effectively plays at half speed (each source sample is held for two output samples, due to interpolation).
|
|
587
|
-
# If rateFactor is 2.0, it plays at double speed (skipping source samples, with interpolation filling the gaps).
|
|
588
|
-
self.playbackPosition += self.rateFactor
|
|
589
|
-
|
|
590
|
-
# This is the point at which we should either loop or stop for the current play segment,
|
|
591
|
-
# so determine the effective end frame for the current segment/loop
|
|
592
|
-
effectiveSegmentEndFrame = self.numFrames -1 # default to end of file
|
|
593
|
-
|
|
594
|
-
if self.looping and self.loopRegionEndFrame > 0:
|
|
595
|
-
# if looping and a specific loop region end is defined, that's our segment end
|
|
596
|
-
effectiveSegmentEndFrame = self.loopRegionEndFrame
|
|
597
|
-
|
|
598
|
-
elif not self.looping and self.targetEndSourceFrame > 0: # play(size) scenario
|
|
599
|
-
# if not looping, but a specific duration was given (play(size)), that defines the segment end
|
|
600
|
-
effectiveSegmentEndFrame = self.targetEndSourceFrame
|
|
601
|
-
|
|
602
|
-
# check for end of segment (loop iteration, play(size) duration, or natural EOF)
|
|
603
|
-
if self.playbackPosition >= effectiveSegmentEndFrame:
|
|
604
|
-
|
|
605
|
-
# we've reached or passed the end of the current audio segment
|
|
606
|
-
if self.looping:
|
|
607
|
-
self.loopsPerformed += 1
|
|
608
|
-
|
|
609
|
-
if self.loopCountTarget != -1 and self.loopsPerformed >= self.loopCountTarget:
|
|
610
|
-
# If we've reached the target number of loops (and it's not infinite looping),
|
|
611
|
-
# stop playback.
|
|
612
|
-
self.isPlaying = False
|
|
613
|
-
self.loopsPerformed = 0 # reset for next play call
|
|
614
|
-
# other loop params (loopCountTarget, loopRegionStartFrame, loopRegionEndFrame) are reset by play()
|
|
615
|
-
|
|
616
|
-
else: # continue looping (either infinite or more loops to go)
|
|
617
|
-
# wrap position back to the start of the loop region
|
|
618
|
-
# This causes playback to jump back to self.loopRegionStartFrame to continue the loop.
|
|
619
|
-
self.playbackPosition = self.loopRegionStartFrame
|
|
620
|
-
|
|
621
|
-
else: # not looping, and reached end of specified segment or natural EOF
|
|
622
|
-
# this handles both play(size) completion and natural end of a non-looping file.
|
|
623
|
-
self.isPlaying = False # stop playback.
|
|
624
|
-
|
|
625
|
-
if self.playbackPosition >= self.numFrames -1: # check if it was natural EOF
|
|
626
|
-
# If we've also reached or passed the actual end of the audio file data,
|
|
627
|
-
# mark that playback ended naturally.
|
|
628
|
-
self.playbackEndedNaturally = True
|
|
629
|
-
|
|
630
|
-
# reset play(size) parameters if they were active
|
|
631
|
-
self.targetEndSourceFrame = -1.0
|
|
632
|
-
self.playDurationSourceFrames = -1.0
|
|
633
|
-
# loop counters are reset by play() or if explicitly stopped.
|
|
634
|
-
# self.loopsPerformed = 0 # reset here too just in case.
|
|
635
|
-
|
|
636
|
-
#### Post-loop/end-of-segment logic, check if isPlaying is still true before interpolation
|
|
637
|
-
# NOTE: This check is crucial. If the logic above (loop completion, segment end) set isPlaying to False,
|
|
638
|
-
# we need to fill the rest of the current audio chunk with silence and exit the sample loop.
|
|
639
|
-
if not self.isPlaying:
|
|
640
|
-
chunkBuffer[i:] = 0.0 # fill remaining part of this chunk with silence
|
|
641
|
-
break # exit per-sample loop
|
|
642
|
-
|
|
643
|
-
#### End of per-sample loop (for i in range(frames))
|
|
644
|
-
|
|
645
|
-
#### Update smoothed pan factor (block-level, after all samples in this chunk are processed)
|
|
646
|
-
# NOTE: To avoid abrupt changes in panning, which can sound like clicks or pops,
|
|
647
|
-
# we smoothly transition the self.currentPanFactor towards self.panTargetFactor over
|
|
648
|
-
# a short duration (self.panSmoothingTotalFrames).
|
|
649
|
-
# This calculation is done once per audio block rather than per sample
|
|
650
|
-
# for efficiency, and because per-sample smoothing would be overkill.
|
|
651
|
-
# check if pan smoothing is still in progress for this block
|
|
652
|
-
if self.panSmoothingFramesProcessed < self.panSmoothingTotalFrames:
|
|
653
|
-
self.panSmoothingFramesProcessed += frames # accumulate frames processed in this callback
|
|
654
|
-
|
|
655
|
-
# check if smoothing has now reached or exceeded the total smoothing duration
|
|
656
|
-
if self.panSmoothingFramesProcessed >= self.panSmoothingTotalFrames:
|
|
657
|
-
self.currentPanFactor = self.panTargetFactor # target reached, snap to it
|
|
658
|
-
self.panSmoothingFramesProcessed = self.panSmoothingTotalFrames # cap it
|
|
659
|
-
|
|
660
|
-
else:
|
|
661
|
-
# smoothing is still in progress, so interpolate current pan factor for the block
|
|
662
|
-
# t is the fraction of the smoothing duration that has passed
|
|
663
|
-
t = self.panSmoothingFramesProcessed / self.panSmoothingTotalFrames
|
|
664
|
-
self.currentPanFactor = self.panInitialFactor + (self.panTargetFactor - self.panInitialFactor) * t
|
|
665
|
-
|
|
666
|
-
else: # smoothing is complete or wasn't active for this block duration
|
|
667
|
-
self.currentPanFactor = self.panTargetFactor # ensure it's at target if smoothing just finished or was already done
|
|
668
|
-
|
|
669
|
-
#### Copy the fully processed chunkBuffer to the output buffer for sounddevice
|
|
670
|
-
# NOTE: Audio samples should typically be within the range -1.0 to 1.0.
|
|
671
|
-
# np.clip ensures that any values outside this range (due to processing, bugs, or loud source material)
|
|
672
|
-
# are clamped to the min/max, preventing potential distortion or errors in the audio output driver.
|
|
673
|
-
outdata[:] = np.clip(chunkBuffer, -1.0, 1.0)
|
|
674
|
-
|
|
675
|
-
#### Handle stream stopping conditions
|
|
676
|
-
# NOTE: Streams are now properly started/stopped by play()/stop() methods
|
|
677
|
-
# This provides cleaner audio management and prevents interference between multiple voices
|
|
678
|
-
# The stream will be stopped when playback ends or stop() is called
|
|
679
|
-
|
|
680
|
-
#### end of audioCallback
|
|
681
|
-
|
|
682
|
-
|
|
194
|
+
#########################################################################
|
|
195
|
+
# PHASE 5: FADE ENVELOPE INITIALIZATION
|
|
196
|
+
# configure smooth fades to prevent clicks/pops
|
|
197
|
+
# NOTE: fades are essential for professional audio quality
|
|
198
|
+
#########################################################################
|
|
199
|
+
|
|
200
|
+
self.fadeInDurationMs = 20 # fade-in duration in milliseconds
|
|
201
|
+
self.fadeInFramesProcessed = 0 # frames processed during current fade-in
|
|
202
|
+
self.isApplyingFadeIn = False # whether fade-in is currently active
|
|
203
|
+
self.fadeInTotalFrames = max(1, int(self.sampleRate * (self.fadeInDurationMs / 1000.0)))
|
|
204
|
+
|
|
205
|
+
self.fadeOutDurationMs = 20 # fade-out duration in milliseconds
|
|
206
|
+
self.fadeOutFramesProcessed = 0 # frames processed during current fade-out
|
|
207
|
+
self.isApplyingFadeOut = False # whether fade-out is currently active
|
|
208
|
+
self.isFadingOutToStop = False # whether fade-out is leading to a stop
|
|
209
|
+
self.isFadingOutToSeek = False # whether fade-out is leading to a seek operation
|
|
210
|
+
self.seekTargetFrameAfterFade = 0.0 # target frame position after seek fade completes
|
|
211
|
+
self.fadeOutTotalFrames = max(1, int(self.sampleRate * (self.fadeOutDurationMs / 1000.0)))
|
|
212
|
+
|
|
213
|
+
#########################################################################
|
|
214
|
+
# PHASE 6: AUDIO STREAM & BUFFER INITIALIZATION
|
|
215
|
+
# pre-allocate audio stream and processing buffers
|
|
216
|
+
# NOTE: pre-allocation is key optimization - eliminates runtime allocations
|
|
217
|
+
#########################################################################
|
|
218
|
+
|
|
219
|
+
self.sdStream = None # sounddevice OutputStream for playback
|
|
220
|
+
self.chunkSize = chunkSize # audio block size to process per callback
|
|
221
|
+
|
|
222
|
+
# pre-allocate buffers for vectorized processing (major performance optimization)
|
|
223
|
+
# NOTE: reusing these buffers across callbacks eliminates memory allocation overhead
|
|
224
|
+
maxChunkSize = chunkSize * 2
|
|
225
|
+
self.chunkBuffer = np.zeros((maxChunkSize, 2), dtype=np.float32)
|
|
226
|
+
self.processedSampleBuffer = np.zeros((maxChunkSize, 2), dtype=np.float32)
|
|
227
|
+
self.readPositions = np.zeros(maxChunkSize, dtype=np.float64)
|
|
228
|
+
self.fadeEnvelope = np.ones(maxChunkSize, dtype=np.float32)
|
|
229
|
+
|
|
230
|
+
#########################################################################
|
|
231
|
+
# PHASE 7: LOOP & PLAYBACK CONTROL INITIALIZATION
|
|
232
|
+
# set up loop regions and playback duration tracking
|
|
233
|
+
#########################################################################
|
|
234
|
+
|
|
235
|
+
self.playbackEndedNaturally = False # whether playback ended naturally (not stopped)
|
|
236
|
+
self.playDurationSourceFrames = -1.0 # play duration in source frames (-1 = play to end)
|
|
237
|
+
self.targetEndSourceFrame = -1.0 # target end frame for play duration (-1 = play to end)
|
|
238
|
+
self.loopRegionStartFrame = 0.0 # start frame of loop region
|
|
239
|
+
self.loopRegionEndFrame = -1.0 # end frame of loop region (-1 means to end of file)
|
|
240
|
+
self.loopCountTarget = -1 # target loop count (-1 = infinite, 0 = no loop, 1+ = specific count)
|
|
241
|
+
self.loopsPerformed = 0 # number of loops completed so far
|
|
242
|
+
self.isClosed = False # track whether close() has been called
|
|
243
|
+
|
|
244
|
+
if self.looping and self.loopCountTarget == -1:
|
|
245
|
+
pass # infinite looping
|
|
246
|
+
elif not self.looping:
|
|
247
|
+
self.loopCountTarget = 0 # play once
|
|
248
|
+
|
|
249
|
+
#########################################################################
|
|
250
|
+
# PHASE 8: AUDIO STREAM CREATION
|
|
251
|
+
# create (but don't start) the audio stream for immediate playback readiness
|
|
252
|
+
# NOTE: stream is created now but started later in play() for maximum efficiency
|
|
253
|
+
#########################################################################
|
|
254
|
+
|
|
255
|
+
self.createStream()
|
|
256
|
+
|
|
257
|
+
### Playback Control Methods ###############################################
|
|
683
258
|
def play(self, startAtBeginning=True, loop=None, playDurationSourceFrames=-1.0,
|
|
684
259
|
loopRegionStartFrame=0.0, loopRegionEndFrame=-1.0, loopCountTarget=None,
|
|
685
260
|
initialLoopsPerformed=0):
|
|
@@ -791,11 +366,13 @@ class _RealtimeAudioPlayer:
|
|
|
791
366
|
self.isPlaying = True
|
|
792
367
|
self.playbackEndedNaturally = False
|
|
793
368
|
else:
|
|
794
|
-
#
|
|
795
|
-
self.
|
|
796
|
-
self.
|
|
797
|
-
self.
|
|
798
|
-
|
|
369
|
+
# stream was somehow lost - recreate it
|
|
370
|
+
self.isClosed = False # reset closed flag to allow recreation
|
|
371
|
+
self.createStream()
|
|
372
|
+
if self.sdStream is not None:
|
|
373
|
+
self.sdStream.start() # start the newly created stream
|
|
374
|
+
self.isPlaying = True
|
|
375
|
+
self.playbackEndedNaturally = False
|
|
799
376
|
|
|
800
377
|
except Exception as e: # stream recreation failed
|
|
801
378
|
print(f"Error with audio stream: {e}")
|
|
@@ -806,11 +383,6 @@ class _RealtimeAudioPlayer:
|
|
|
806
383
|
self.isApplyingFadeIn = True
|
|
807
384
|
self.fadeInFramesProcessed = 0
|
|
808
385
|
|
|
809
|
-
|
|
810
|
-
def getLoopsPerformed(self):
|
|
811
|
-
return self.loopsPerformed # number of loops completed
|
|
812
|
-
|
|
813
|
-
|
|
814
386
|
def stop(self, immediate=False):
|
|
815
387
|
"""
|
|
816
388
|
Stops audio playback with optional immediate termination.
|
|
@@ -819,7 +391,6 @@ class _RealtimeAudioPlayer:
|
|
|
819
391
|
(fade-out stop). The method handles cleanup of audio streams, resets playback
|
|
820
392
|
state variables, and manages fade transitions appropriately.
|
|
821
393
|
"""
|
|
822
|
-
|
|
823
394
|
# handle case where already stopped (but may have pending fade-out)
|
|
824
395
|
if not self.isPlaying and not self.isApplyingFadeOut:
|
|
825
396
|
|
|
@@ -827,8 +398,8 @@ class _RealtimeAudioPlayer:
|
|
|
827
398
|
if self.sdStream and not self.sdStream.stopped:
|
|
828
399
|
try:
|
|
829
400
|
self.sdStream.stop()
|
|
830
|
-
except Exception
|
|
831
|
-
#
|
|
401
|
+
except Exception:
|
|
402
|
+
# ignore errors during cleanup
|
|
832
403
|
pass
|
|
833
404
|
|
|
834
405
|
# reset all playback state variables
|
|
@@ -855,8 +426,8 @@ class _RealtimeAudioPlayer:
|
|
|
855
426
|
if self.sdStream and not self.sdStream.stopped:
|
|
856
427
|
try:
|
|
857
428
|
self.sdStream.stop()
|
|
858
|
-
except Exception
|
|
859
|
-
#
|
|
429
|
+
except Exception:
|
|
430
|
+
# ignore errors during cleanup
|
|
860
431
|
pass
|
|
861
432
|
|
|
862
433
|
# reset all playback state variables for immediate stop
|
|
@@ -879,58 +450,489 @@ class _RealtimeAudioPlayer:
|
|
|
879
450
|
|
|
880
451
|
# now, the sounddevice stream is stopped
|
|
881
452
|
|
|
453
|
+
### Playback State Methods #################################################
|
|
454
|
+
def getCurrentTime(self):
|
|
455
|
+
# calculate current time based on position and sample rate
|
|
456
|
+
currentTime = self.playbackPosition / self.sampleRate
|
|
457
|
+
return currentTime # return current time
|
|
458
|
+
|
|
459
|
+
def setCurrentTime(self, timeSeconds):
|
|
460
|
+
# check that timeSeconds is a valid non-negative number. if not, default to 0.0
|
|
461
|
+
if not isinstance(timeSeconds, (int, float)) or timeSeconds < 0:
|
|
462
|
+
timeSeconds = 0.0
|
|
882
463
|
|
|
883
|
-
|
|
884
|
-
|
|
464
|
+
# convert the requested time in seconds to a floating-point frame index
|
|
465
|
+
originalTargetFrameFloat = timeSeconds * self.sampleRate
|
|
885
466
|
|
|
886
|
-
#
|
|
887
|
-
|
|
888
|
-
self.isApplyingFadeOut = False
|
|
889
|
-
self.isFadingOutToStop = False
|
|
467
|
+
# basic ZC adjustment for now, will be enhanced with fade-seek-fade
|
|
468
|
+
actualTargetFrame = self.findNextZeroCrossing(originalTargetFrameFloat)
|
|
890
469
|
|
|
891
|
-
if
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
470
|
+
# if playing and conditions met for smooth seek
|
|
471
|
+
if actualTargetFrame >= self.numFrames and not self.looping:
|
|
472
|
+
# set playback position to the requested frame, or to the end if beyond available frames
|
|
473
|
+
self.playbackPosition = float(self.numFrames -1)
|
|
474
|
+
self.playbackEndedNaturally = True
|
|
896
475
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
476
|
+
else:
|
|
477
|
+
self.playbackPosition = actualTargetFrame # set playback position to the next zero crossing
|
|
478
|
+
self.playbackEndedNaturally = False # reset natural end flag if jumping
|
|
900
479
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if pae.args[1] == sd.PaErrorCode.paNotInitialized: # paNotInitialized = -10000
|
|
904
|
-
pass # suppress error if PortAudio is already down
|
|
480
|
+
def getLoopsPerformed(self):
|
|
481
|
+
return self.loopsPerformed # number of loops completed
|
|
905
482
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
483
|
+
def getFrameRate(self):
|
|
484
|
+
return self.sampleRate # sample rate of the audio
|
|
485
|
+
|
|
486
|
+
### Audio Parameter Methods ################################################
|
|
487
|
+
def setPitch(self, midiPitch):
|
|
488
|
+
# set the playback pitch by converting midiPitch (0-127) to frequency and updating rate factor
|
|
489
|
+
if (isinstance(midiPitch, (int, float)) and 0 <= midiPitch <= 127):
|
|
490
|
+
targetFrequencyHz = noteToFreq(float(midiPitch)) # convert midi pitch to frequency
|
|
491
|
+
self.setFrequency(targetFrequencyHz) # set playback frequency accordingly
|
|
492
|
+
|
|
493
|
+
def getPitch(self):
|
|
494
|
+
currentFreq = self.getFrequency() # get freq
|
|
495
|
+
currentPitch = freqToNote(currentFreq) # convert to pitch
|
|
496
|
+
return currentPitch # return current pitch
|
|
497
|
+
|
|
498
|
+
def getBasePitch(self):
|
|
499
|
+
return self.basePitch # original pitch of the sample
|
|
500
|
+
|
|
501
|
+
def setFrequency(self, targetFrequencyHz):
|
|
502
|
+
if isinstance(targetFrequencyHz, (int, float)) and self.baseFrequency > 0:
|
|
503
|
+
|
|
504
|
+
if targetFrequencyHz > 0: # frequency is valid
|
|
505
|
+
newRateFactor = targetFrequencyHz / self.baseFrequency
|
|
506
|
+
self.setRateFactor(newRateFactor)
|
|
507
|
+
|
|
508
|
+
else: # target frequency too small
|
|
509
|
+
self.setRateFactor(0.00001) # avoid zero or negative, effectively silent/pause
|
|
510
|
+
|
|
511
|
+
def getFrequency(self):
|
|
512
|
+
# calculate current frequency based on base and rate
|
|
513
|
+
currentFreq = self.baseFrequency * self.rateFactor
|
|
514
|
+
return currentFreq # return current frequency
|
|
515
|
+
|
|
516
|
+
def getBaseFrequency(self):
|
|
517
|
+
return self.baseFrequency # original frequency of the sample
|
|
518
|
+
|
|
519
|
+
def setVolumeFactor(self, factor):
|
|
520
|
+
if isinstance(factor, (int, float)):
|
|
521
|
+
# valid factor, so set volume
|
|
522
|
+
self.volumeFactor = max(0.0, min(1.0, float(factor)))
|
|
523
|
+
# print(f"Set to {self.volumeFactor:.3f}")
|
|
524
|
+
|
|
525
|
+
else:
|
|
526
|
+
# factor is invalid type, so default to full volume
|
|
527
|
+
self.volumeFactor = 1.0
|
|
528
|
+
|
|
529
|
+
def getVolumeFactor(self):
|
|
530
|
+
return self.volumeFactor # return volume factor
|
|
531
|
+
|
|
532
|
+
def setPanFactor(self, panFactor):
|
|
533
|
+
# clamp panFactor to a float in [-1.0, 1.0]; if invalid, default to center (0.0)
|
|
534
|
+
if not isinstance(panFactor, (int, float)):
|
|
535
|
+
clampedPanFactor = 0.0 # not a number, so use center
|
|
911
536
|
|
|
537
|
+
else:
|
|
538
|
+
clampedPanFactor = max(-1.0, min(1.0, float(panFactor))) # clamp to valid range
|
|
912
539
|
|
|
913
|
-
|
|
540
|
+
# if the new pan target is different enough from the current target, start smoothing ramp
|
|
541
|
+
if abs(self.panTargetFactor - clampedPanFactor) > 0.001: # significant change
|
|
542
|
+
self.panTargetFactor = clampedPanFactor # set new pan target
|
|
543
|
+
self.panInitialFactor = self.currentPanFactor # remember current pan as ramp start
|
|
544
|
+
self.panSmoothingFramesProcessed = 0 # reset smoothing progress
|
|
545
|
+
|
|
546
|
+
def getPanFactor(self):
|
|
547
|
+
return self.panTargetFactor # return pan factor
|
|
548
|
+
|
|
549
|
+
def setRateFactor(self, factor):
|
|
550
|
+
# check if the provided factor is a number (int or float)
|
|
551
|
+
if isinstance(factor, (int, float)):
|
|
552
|
+
|
|
553
|
+
# if factor is zero or negative, set to a very small positive value to effectively pause playback
|
|
554
|
+
if factor <= 0:
|
|
555
|
+
self.rateFactor = 0.00001 # avoid zero or negative, effectively silent/pause
|
|
556
|
+
|
|
557
|
+
else:
|
|
558
|
+
# otherwise, set the rate factor to the given value (as float)
|
|
559
|
+
self.rateFactor = float(factor)
|
|
560
|
+
# print(f"Set to {self.rateFactor:.4f}")
|
|
561
|
+
|
|
562
|
+
else:
|
|
563
|
+
# if input is not a number, default to 1x speed
|
|
564
|
+
self.rateFactor = 1.0
|
|
565
|
+
|
|
566
|
+
def getRateFactor(self):
|
|
567
|
+
return self.rateFactor # return rate factor
|
|
568
|
+
|
|
569
|
+
### Internal Audio Engine Methods #########################################
|
|
570
|
+
def createStream(self):
|
|
571
|
+
"""
|
|
572
|
+
Creates and starts the audio stream. This method is called during initialization
|
|
573
|
+
to pre-allocate the stream for maximum efficiency.
|
|
574
|
+
"""
|
|
575
|
+
try:
|
|
576
|
+
# create the sounddevice output stream for playback
|
|
577
|
+
self.sdStream = sd.OutputStream(
|
|
578
|
+
samplerate=self.sampleRate,
|
|
579
|
+
blocksize=self.chunkSize,
|
|
580
|
+
channels=self.numChannels,
|
|
581
|
+
callback=self.audioCallback
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# Don't start the stream here - it will be started in play() method
|
|
585
|
+
# self.sdStream.start() # Stream will be started when needed
|
|
586
|
+
|
|
587
|
+
except Exception as e:
|
|
588
|
+
print(f"Error creating audio stream: {e}")
|
|
589
|
+
self.sdStream = None
|
|
590
|
+
raise
|
|
591
|
+
|
|
592
|
+
def findNextZeroCrossing(self, startFrameFloat, searchWindowFrames=256):
|
|
914
593
|
"""
|
|
915
|
-
|
|
916
|
-
|
|
594
|
+
Find the nearest zero-crossing point in the audio waveform.
|
|
595
|
+
|
|
596
|
+
Zero-crossings are points where the audio signal crosses the zero amplitude line.
|
|
597
|
+
Starting/stopping playback at zero-crossings prevents audible clicks and pops that
|
|
598
|
+
occur when abruptly cutting audio at non-zero amplitudes. This is especially important
|
|
599
|
+
for seek operations and loop boundaries.
|
|
600
|
+
|
|
601
|
+
Uses vectorized NumPy operations to efficiently search a window of samples rather than
|
|
602
|
+
checking each sample individually.
|
|
917
603
|
"""
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
self.sdStream.stop()
|
|
922
|
-
if not self.sdStream.closed:
|
|
923
|
-
self.sdStream.close()
|
|
924
|
-
except Exception as e:
|
|
925
|
-
# ignore errors during cleanup
|
|
926
|
-
pass
|
|
927
|
-
finally:
|
|
928
|
-
self.sdStream = None
|
|
604
|
+
# convert floating-point frame position to integer and clamp to valid range
|
|
605
|
+
startFrame = int(np.floor(startFrameFloat))
|
|
606
|
+
startFrame = max(0, min(startFrame, self.numFrames - 1))
|
|
929
607
|
|
|
930
|
-
#
|
|
931
|
-
|
|
608
|
+
# limit search window to prevent going beyond audio data boundaries
|
|
609
|
+
# NOTE: 256 frames is ~5ms at 48kHz - short enough to be inaudible
|
|
610
|
+
endSearchFrame = min(self.numFrames - 1, startFrame + searchWindowFrames)
|
|
932
611
|
|
|
612
|
+
if startFrame >= self.numFrames - 1:
|
|
613
|
+
return float(min(startFrame, self.numFrames - 1))
|
|
933
614
|
|
|
615
|
+
# extract audio segment for search (use left channel for stereo)
|
|
616
|
+
if self.numChannels == 1:
|
|
617
|
+
segment = self.audioData[startFrame:endSearchFrame]
|
|
618
|
+
else:
|
|
619
|
+
segment = self.audioData[startFrame:endSearchFrame, 0]
|
|
620
|
+
|
|
621
|
+
# check if we have enough samples to search
|
|
622
|
+
if len(segment) < 2:
|
|
623
|
+
return float(startFrame)
|
|
624
|
+
|
|
625
|
+
# vectorized zero-crossing detection - much faster than sample-by-sample loop
|
|
626
|
+
# first, look for samples that are exactly zero (rare but ideal)
|
|
627
|
+
zeroIndices = np.where(segment == 0.0)[0]
|
|
628
|
+
if len(zeroIndices) > 0:
|
|
629
|
+
return float(startFrame + zeroIndices[0])
|
|
630
|
+
|
|
631
|
+
# find sign changes (positive to negative or vice versa) which indicate zero-crossings
|
|
632
|
+
# NOTE: np.diff finds differences between consecutive samples, sign changes indicate crossings
|
|
633
|
+
signChanges = np.diff(np.sign(segment))
|
|
634
|
+
crossingIndices = np.where(signChanges != 0)[0]
|
|
635
|
+
|
|
636
|
+
if len(crossingIndices) > 0:
|
|
637
|
+
# return frame just after the crossing
|
|
638
|
+
return float(startFrame + crossingIndices[0] + 1)
|
|
639
|
+
|
|
640
|
+
# no crossing found in search window - return original position
|
|
641
|
+
# NOTE: this is okay, the fade envelope will still minimize clicks
|
|
642
|
+
return float(startFrame)
|
|
643
|
+
|
|
644
|
+
def audioCallback(self, outdata, frames, time, status):
|
|
645
|
+
"""
|
|
646
|
+
Real-time audio processing callback.
|
|
647
|
+
|
|
648
|
+
Called repeatedly by sounddevice to fill the audio output buffer. Processes
|
|
649
|
+
entire chunks using vectorized NumPy operations for maximum performance,
|
|
650
|
+
enabling high polyphony without CPU bottlenecks.
|
|
651
|
+
|
|
652
|
+
Five-phase processing pipeline:
|
|
653
|
+
1. Setup & Validation
|
|
654
|
+
2. Pitch Shifting (via time-stretch interpolation)
|
|
655
|
+
3. Dynamics Processing (volume, fades)
|
|
656
|
+
4. Spatial Processing (panning)
|
|
657
|
+
5. Boundary Handling (looping, playback completion)
|
|
658
|
+
"""
|
|
659
|
+
#########################################################################
|
|
660
|
+
# PHASE 1: SETUP & VALIDATION
|
|
661
|
+
# validate audio state and prepare for processing
|
|
662
|
+
#########################################################################
|
|
663
|
+
|
|
664
|
+
# handle edge cases that require silence
|
|
665
|
+
if self.numFrames == 0:
|
|
666
|
+
outdata.fill(0)
|
|
667
|
+
if self.isPlaying:
|
|
668
|
+
self.isPlaying = False
|
|
669
|
+
raise sd.CallbackStop
|
|
670
|
+
|
|
671
|
+
# early return for silence conditions
|
|
672
|
+
if not self.isPlaying or self.rateFactor <= 0.000001:
|
|
673
|
+
outdata.fill(0)
|
|
674
|
+
if self.isApplyingFadeOut and self.isFadingOutToStop and self.rateFactor <= 0.000001:
|
|
675
|
+
self.isPlaying = False
|
|
676
|
+
self.isApplyingFadeOut = False
|
|
677
|
+
self.isFadingOutToStop = False
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
# cache attributes as local variables
|
|
681
|
+
# NOTE: reduces attribute lookup overhead in hot path (significant performance gain)
|
|
682
|
+
numOutputChannels = outdata.shape[1]
|
|
683
|
+
numSourceChannels = self.numChannels
|
|
684
|
+
rateFactor = self.rateFactor
|
|
685
|
+
volumeFactor = self.volumeFactor
|
|
686
|
+
currentPan = self.currentPanFactor
|
|
687
|
+
looping = self.looping
|
|
688
|
+
numAudioFrames = self.numFrames
|
|
689
|
+
|
|
690
|
+
# determine where playback should end for this segment
|
|
691
|
+
# NOTE: supports three modes: natural EOF, loop region end, or play(size) duration
|
|
692
|
+
effectiveSegmentEndFrame = numAudioFrames - 1
|
|
693
|
+
if looping and self.loopRegionEndFrame > 0:
|
|
694
|
+
effectiveSegmentEndFrame = self.loopRegionEndFrame
|
|
695
|
+
elif not looping and self.targetEndSourceFrame > 0:
|
|
696
|
+
effectiveSegmentEndFrame = self.targetEndSourceFrame
|
|
697
|
+
|
|
698
|
+
# calculate safe processing range before hitting boundaries
|
|
699
|
+
# NOTE: batch boundary detection avoids per-sample checks (major optimization)
|
|
700
|
+
framesToProcess = frames
|
|
701
|
+
willHitBoundary = False
|
|
702
|
+
boundaryFrame = -1
|
|
703
|
+
|
|
704
|
+
if effectiveSegmentEndFrame > 0:
|
|
705
|
+
framesToEndpoint = (effectiveSegmentEndFrame - self.playbackPosition) / rateFactor
|
|
706
|
+
if framesToEndpoint < frames and framesToEndpoint > 0:
|
|
707
|
+
willHitBoundary = True
|
|
708
|
+
boundaryFrame = int(np.floor(framesToEndpoint))
|
|
709
|
+
framesToProcess = min(frames, boundaryFrame + 1)
|
|
710
|
+
|
|
711
|
+
###########################################################################
|
|
712
|
+
# PHASE 2: PITCH SHIFTING VIA TIME-STRETCH INTERPOLATION
|
|
713
|
+
# generate audio at requested pitch by resampling source audio
|
|
714
|
+
# NOTE: this is where pitch/frequency changes are actually applied
|
|
715
|
+
###########################################################################
|
|
716
|
+
|
|
717
|
+
# generate array of source read positions for entire chunk
|
|
718
|
+
# NOTE: vectorized - processes all frames at once instead of per-sample loop
|
|
719
|
+
readPositionsArray = self.playbackPosition + np.arange(framesToProcess, dtype=np.float64) * rateFactor
|
|
720
|
+
readPositionsArray = np.clip(readPositionsArray, 0.0, numAudioFrames - 1.0)
|
|
721
|
+
|
|
722
|
+
# calculate interpolation parameters for smooth resampling
|
|
723
|
+
# NOTE: integer positions (samples to read) and fractions (for interpolation)
|
|
724
|
+
readPosInt1 = np.floor(readPositionsArray).astype(np.int32)
|
|
725
|
+
readPosInt2 = np.minimum(readPosInt1 + 1, numAudioFrames - 1)
|
|
726
|
+
fractions = readPositionsArray - readPosInt1
|
|
727
|
+
|
|
728
|
+
# perform vectorized linear interpolation
|
|
729
|
+
# NOTE: handles mono/stereo automatically, interpolates entire chunk at once
|
|
730
|
+
if numSourceChannels == 1: # mono source
|
|
731
|
+
samples1 = self.audioData[readPosInt1]
|
|
732
|
+
samples2 = self.audioData[readPosInt2]
|
|
733
|
+
interpolatedSamples = samples1 + (samples2 - samples1) * fractions
|
|
734
|
+
interpolatedSamples = interpolatedSamples.reshape(-1, 1) # shape: (framesToProcess, 1)
|
|
735
|
+
|
|
736
|
+
else: # stereo source
|
|
737
|
+
samples1 = self.audioData[readPosInt1, :]
|
|
738
|
+
samples2 = self.audioData[readPosInt2, :]
|
|
739
|
+
fractions2D = fractions.reshape(-1, 1)
|
|
740
|
+
interpolatedSamples = samples1 + (samples2 - samples1) * fractions2D # shape: (framesToProcess, 2)
|
|
741
|
+
|
|
742
|
+
#########################################################################
|
|
743
|
+
# PHASE 3: DYNAMICS PROCESSING
|
|
744
|
+
# apply volume control and smooth fades to prevent clicks/pops
|
|
745
|
+
#########################################################################
|
|
746
|
+
|
|
747
|
+
# apply volume scaling
|
|
748
|
+
processedSamples = interpolatedSamples * volumeFactor
|
|
749
|
+
|
|
750
|
+
# generate fade envelope for smooth transitions
|
|
751
|
+
# NOTE: fades prevent audible clicks when starting/stopping audio
|
|
752
|
+
fadeEnv = np.ones(framesToProcess, dtype=np.float32)
|
|
753
|
+
|
|
754
|
+
# apply fade-in envelope if transitioning from silence
|
|
755
|
+
if self.isApplyingFadeIn:
|
|
756
|
+
remainingFadeInFrames = self.fadeInTotalFrames - self.fadeInFramesProcessed
|
|
757
|
+
|
|
758
|
+
if remainingFadeInFrames > 0:
|
|
759
|
+
fadeInLength = min(remainingFadeInFrames, framesToProcess)
|
|
760
|
+
startValue = self.fadeInFramesProcessed / self.fadeInTotalFrames
|
|
761
|
+
endValue = (self.fadeInFramesProcessed + fadeInLength) / self.fadeInTotalFrames
|
|
762
|
+
fadeEnv[:fadeInLength] *= np.linspace(startValue, endValue, fadeInLength, dtype=np.float32)
|
|
763
|
+
self.fadeInFramesProcessed += fadeInLength
|
|
764
|
+
|
|
765
|
+
if self.fadeInFramesProcessed >= self.fadeInTotalFrames:
|
|
766
|
+
self.isApplyingFadeIn = False
|
|
767
|
+
|
|
768
|
+
# apply fade-out envelope if transitioning to silence
|
|
769
|
+
if self.isApplyingFadeOut:
|
|
770
|
+
remainingFadeOutFrames = self.fadeOutTotalFrames - self.fadeOutFramesProcessed
|
|
771
|
+
if remainingFadeOutFrames > 0:
|
|
772
|
+
fadeOutLength = min(remainingFadeOutFrames, framesToProcess)
|
|
773
|
+
startValue = 1.0 - (self.fadeOutFramesProcessed / self.fadeOutTotalFrames)
|
|
774
|
+
endValue = 1.0 - ((self.fadeOutFramesProcessed + fadeOutLength) / self.fadeOutTotalFrames)
|
|
775
|
+
fadeEnv[:fadeOutLength] *= np.linspace(startValue, endValue, fadeOutLength, dtype=np.float32)
|
|
776
|
+
self.fadeOutFramesProcessed += fadeOutLength
|
|
777
|
+
|
|
778
|
+
if self.fadeOutFramesProcessed >= self.fadeOutTotalFrames:
|
|
779
|
+
self.isApplyingFadeOut = False
|
|
780
|
+
|
|
781
|
+
if self.isFadingOutToStop:
|
|
782
|
+
self.isPlaying = False
|
|
783
|
+
self.isFadingOutToStop = False
|
|
784
|
+
self.targetEndSourceFrame = -1.0
|
|
785
|
+
self.playDurationSourceFrames = -1.0
|
|
786
|
+
# fill remainder with silence
|
|
787
|
+
fadeEnv[fadeOutLength:] = 0.0
|
|
788
|
+
|
|
789
|
+
else:
|
|
790
|
+
fadeEnv[:] = 0.0
|
|
791
|
+
|
|
792
|
+
# apply combined fade envelope to audio
|
|
793
|
+
processedSamples = processedSamples * fadeEnv.reshape(-1, 1)
|
|
794
|
+
|
|
795
|
+
#########################################################################
|
|
796
|
+
# PHASE 4: SPATIAL PROCESSING
|
|
797
|
+
# position audio in stereo field using psychoacoustic panning
|
|
798
|
+
#########################################################################
|
|
799
|
+
|
|
800
|
+
# apply constant-power panning for stereo output
|
|
801
|
+
# NOTE: constant-power law maintains perceived loudness across pan positions
|
|
802
|
+
if numOutputChannels == 2: # stereo output
|
|
803
|
+
panAngleRad = (currentPan + 1.0) * np.pi / 4.0
|
|
804
|
+
leftGain = np.cos(panAngleRad)
|
|
805
|
+
rightGain = np.sin(panAngleRad)
|
|
806
|
+
|
|
807
|
+
if numSourceChannels == 1: # mono to stereo
|
|
808
|
+
outdata[:framesToProcess, 0] = processedSamples[:, 0] * leftGain
|
|
809
|
+
outdata[:framesToProcess, 1] = processedSamples[:, 0] * rightGain
|
|
810
|
+
else: # stereo to stereo
|
|
811
|
+
outdata[:framesToProcess, 0] = processedSamples[:, 0] * leftGain
|
|
812
|
+
outdata[:framesToProcess, 1] = processedSamples[:, 1] * rightGain
|
|
813
|
+
|
|
814
|
+
# handle mono output (no panning needed, mix stereo to mono if needed)
|
|
815
|
+
else: # mono output
|
|
816
|
+
if numSourceChannels == 1: # mono to mono
|
|
817
|
+
outdata[:framesToProcess, 0] = processedSamples[:, 0]
|
|
818
|
+
else: # stereo to mono (mixdown)
|
|
819
|
+
outdata[:framesToProcess, 0] = (processedSamples[:, 0] + processedSamples[:, 1]) * 0.5
|
|
820
|
+
|
|
821
|
+
#########################################################################
|
|
822
|
+
# PHASE 5: BOUNDARY HANDLING & LOOP MANAGEMENT
|
|
823
|
+
# handle end-of-audio, looping, and playback completion
|
|
824
|
+
#########################################################################
|
|
825
|
+
|
|
826
|
+
# update playback position for next callback
|
|
827
|
+
self.playbackPosition += framesToProcess * rateFactor
|
|
828
|
+
|
|
829
|
+
# handle segment boundaries (loop wrap or playback end)
|
|
830
|
+
if willHitBoundary or self.playbackPosition >= effectiveSegmentEndFrame:
|
|
831
|
+
if looping:
|
|
832
|
+
self.loopsPerformed += 1
|
|
833
|
+
if self.loopCountTarget != -1 and self.loopsPerformed >= self.loopCountTarget:
|
|
834
|
+
# loop count reached - stop playback
|
|
835
|
+
self.isPlaying = False
|
|
836
|
+
self.loopsPerformed = 0
|
|
837
|
+
# fill remainder with silence
|
|
838
|
+
if framesToProcess < frames:
|
|
839
|
+
outdata[framesToProcess:, :] = 0.0
|
|
840
|
+
else:
|
|
841
|
+
# continue looping - wrap back to loop start
|
|
842
|
+
self.playbackPosition = self.loopRegionStartFrame
|
|
843
|
+
if framesToProcess < frames:
|
|
844
|
+
remainingFrames = frames - framesToProcess
|
|
845
|
+
tempOutdata = outdata[framesToProcess:, :]
|
|
846
|
+
self.audioCallback(tempOutdata, remainingFrames, time, status)
|
|
847
|
+
else:
|
|
848
|
+
# non-looping playback reached end
|
|
849
|
+
self.isPlaying = False
|
|
850
|
+
if self.playbackPosition >= numAudioFrames - 1:
|
|
851
|
+
self.playbackEndedNaturally = True
|
|
852
|
+
self.targetEndSourceFrame = -1.0
|
|
853
|
+
self.playDurationSourceFrames = -1.0
|
|
854
|
+
# fill remainder with silence
|
|
855
|
+
if framesToProcess < frames:
|
|
856
|
+
outdata[framesToProcess:, :] = 0.0
|
|
857
|
+
|
|
858
|
+
# safety - fill any unfilled portion with silence
|
|
859
|
+
if framesToProcess < frames and self.isPlaying:
|
|
860
|
+
outdata[framesToProcess:, :] = 0.0
|
|
861
|
+
|
|
862
|
+
# update pan smoothing (prevents clicks from abrupt pan changes)
|
|
863
|
+
# NOTE: processed at block level for efficiency
|
|
864
|
+
if self.panSmoothingFramesProcessed < self.panSmoothingTotalFrames:
|
|
865
|
+
self.panSmoothingFramesProcessed += frames
|
|
866
|
+
|
|
867
|
+
if self.panSmoothingFramesProcessed >= self.panSmoothingTotalFrames:
|
|
868
|
+
self.currentPanFactor = self.panTargetFactor
|
|
869
|
+
self.panSmoothingFramesProcessed = self.panSmoothingTotalFrames
|
|
870
|
+
else:
|
|
871
|
+
t = self.panSmoothingFramesProcessed / self.panSmoothingTotalFrames
|
|
872
|
+
self.currentPanFactor = self.panInitialFactor + (self.panTargetFactor - self.panInitialFactor) * t
|
|
873
|
+
|
|
874
|
+
else:
|
|
875
|
+
self.currentPanFactor = self.panTargetFactor
|
|
876
|
+
|
|
877
|
+
# safety clipping to prevent distortion
|
|
878
|
+
# NOTE: ensures all samples are within valid range for audio hardware
|
|
879
|
+
np.clip(outdata, -1.0, 1.0, out=outdata)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
### Cleanup Methods #######################################################
|
|
934
883
|
def __del__(self):
|
|
935
|
-
|
|
936
|
-
|
|
884
|
+
"""
|
|
885
|
+
Destructor - ensures resources are released when object is garbage collected.
|
|
886
|
+
|
|
887
|
+
Safely calls close() and suppresses all exceptions since we can't handle them
|
|
888
|
+
during garbage collection. This is critical because __del__ may be called during
|
|
889
|
+
interpreter shutdown when other objects/modules may already be cleaned up.
|
|
890
|
+
"""
|
|
891
|
+
try:
|
|
892
|
+
self.close()
|
|
893
|
+
except Exception:
|
|
894
|
+
pass # never let exceptions escape from __del__
|
|
895
|
+
|
|
896
|
+
def close(self):
|
|
897
|
+
"""
|
|
898
|
+
Release all audio resources and stop playback.
|
|
899
|
+
|
|
900
|
+
Safe to call multiple times - subsequent calls are ignored. After calling close(),
|
|
901
|
+
the player can still be used again via play() which will recreate the stream.
|
|
902
|
+
|
|
903
|
+
This method stops any active playback, cancels pending fades, and releases the
|
|
904
|
+
audio stream resources. It's automatically called during garbage collection but
|
|
905
|
+
can also be called explicitly for immediate cleanup.
|
|
906
|
+
"""
|
|
907
|
+
if not self.isClosed: # prevent double cleanup
|
|
908
|
+
self.isClosed = True # mark as closed before attempting cleanup
|
|
909
|
+
self.isPlaying = False # ensure any playback logic stops
|
|
910
|
+
|
|
911
|
+
# cancel any pending fades that might try to operate on a closing stream
|
|
912
|
+
self.isApplyingFadeIn = False
|
|
913
|
+
self.isApplyingFadeOut = False
|
|
914
|
+
self.isFadingOutToStop = False
|
|
915
|
+
|
|
916
|
+
if self.sdStream:
|
|
917
|
+
try:
|
|
918
|
+
# check if stream is active before trying to stop
|
|
919
|
+
if not self.sdStream.stopped:
|
|
920
|
+
self.sdStream.stop() # stop stream activity
|
|
921
|
+
|
|
922
|
+
# re-check because .stop() could have been called
|
|
923
|
+
if not self.sdStream.closed:
|
|
924
|
+
self.sdStream.close() # release resources
|
|
925
|
+
|
|
926
|
+
except Exception as e:
|
|
927
|
+
# catch all exceptions for robust cleanup
|
|
928
|
+
if isinstance(e, sd.PortAudioError):
|
|
929
|
+
# if PortAudio is already uninitialized (e.g. during atexit), suppress error
|
|
930
|
+
paNotInitialized = getattr(sd, 'PaErrorCode', None)
|
|
931
|
+
if paNotInitialized and len(e.args) > 1 and e.args[1] == paNotInitialized.paNotInitialized:
|
|
932
|
+
pass # suppress error if PortAudio is already down
|
|
933
|
+
else:
|
|
934
|
+
print(f"PortAudioError during stream stop/close: {e}")
|
|
935
|
+
else:
|
|
936
|
+
print(f"Error during stream cleanup: {e}")
|
|
937
|
+
finally:
|
|
938
|
+
self.sdStream = None
|