7
7
import soundfile as sf
8
8
import sounddevice as sd
9
9
from scipy .signal import resample
10
+ from api .enums import SoundEffect
10
11
from api .interface import SoundConfig
11
12
from services .pub_sub import PubSub
12
- from services .sound_effects import get_sound_effects
13
+ from services .sound_effects import (
14
+ get_additional_layer_file ,
15
+ get_azure_workaround_gain_boost ,
16
+ get_sound_effects ,
17
+ )
13
18
14
19
15
20
class AudioPlayer :
@@ -29,6 +34,9 @@ def __init__(
29
34
self .stream_event = PubSub ()
30
35
self .on_playback_started = on_playback_started
31
36
self .on_playback_finished = on_playback_finished
37
+ self .sample_dir = path .join (
38
+ path .abspath (path .dirname (__file__ )), "../audio_samples"
39
+ )
32
40
33
41
def set_event_loop (self , loop : asyncio .AbstractEventLoop ):
34
42
self .event_loop = loop
@@ -37,14 +45,51 @@ def start_playback(self, audio, sample_rate, channels, finished_callback):
37
45
def callback (outdata , frames , time , status ):
38
46
nonlocal playhead
39
47
chunksize = frames * channels
40
- current_chunk = audio [playhead : playhead + chunksize ].reshape (- 1 , channels )
41
- if current_chunk .shape [0 ] < frames :
42
- outdata [: current_chunk .shape [0 ]] = current_chunk
43
- outdata [current_chunk .shape [0 ] :] = 0 # Fill the rest with zeros
44
- raise sd .CallbackStop # Stop the stream after playing the current chunk
48
+
49
+ if playhead * channels >= len (audio ):
50
+ if np .issubdtype (outdata .dtype , np .floating ):
51
+ outdata .fill (0.0 ) # Fill with zero for floats
52
+ else :
53
+ outdata [:] = bytes (
54
+ len (outdata )
55
+ ) # Fill with zeros for buffer of int types
56
+ raise sd .CallbackStop
57
+
58
+ end = min (playhead + chunksize , len (audio ) // channels )
59
+ current_chunk = audio [playhead :end ]
60
+
61
+ if channels > 1 and current_chunk .ndim == 1 :
62
+ current_chunk = np .tile (current_chunk [:, None ], (1 , channels )).flatten ()
63
+
64
+ # It's critical that current_chunk matches the number of elements in outdata
65
+ required_length = frames * channels
66
+ current_chunk = current_chunk [:required_length ]
67
+
68
+ if len (current_chunk ) < required_length :
69
+ current_chunk = np .pad (
70
+ current_chunk , (0 , required_length - len (current_chunk )), "constant"
71
+ )
72
+
73
+ if outdata .dtype == np .float32 or outdata .dtype == np .float64 :
74
+ outdata [:required_length ] = current_chunk .astype (outdata .dtype ).reshape (
75
+ outdata .shape
76
+ )
45
77
else :
46
- outdata [:] = current_chunk
47
- playhead += chunksize # Advance the playhead
78
+ current_chunk_bytes = current_chunk .astype (outdata .dtype ).tobytes ()
79
+ outdata [: len (current_chunk_bytes )] = current_chunk_bytes [
80
+ : len (outdata )
81
+ ]
82
+
83
+ playhead += chunksize
84
+
85
+ if end >= len (audio ):
86
+ if np .issubdtype (outdata .dtype , np .floating ):
87
+ outdata .fill (0.0 ) # Fill with zero for floats
88
+ else :
89
+ outdata [:] = bytes (
90
+ len (outdata )
91
+ ) # Fill with zeros buffer of int types
92
+ raise sd .CallbackStop
48
93
49
94
playhead = 0 # Tracks the position in the audio
50
95
@@ -74,6 +119,7 @@ async def play_with_effects(
74
119
input_data : bytes | tuple ,
75
120
config : SoundConfig ,
76
121
wingman_name : str = None ,
122
+ mixed_layer_gain_boost_db : float = - 9.0 ,
77
123
):
78
124
if isinstance (input_data , bytes ):
79
125
audio , sample_rate = self ._get_audio_from_stream (input_data )
@@ -90,6 +136,20 @@ async def play_with_effects(
90
136
for sound_effect in sound_effects :
91
137
audio = sound_effect (audio , sample_rate )
92
138
139
+ mixed_layer_file = None
140
+ for effect in config .effects :
141
+ if not mixed_layer_file :
142
+ mixed_layer_file = get_additional_layer_file (effect )
143
+
144
+ if mixed_layer_file :
145
+ audio = self ._mix_in_layer (
146
+ audio , sample_rate , mixed_layer_file , mixed_layer_gain_boost_db
147
+ )
148
+
149
+ contains_high_end_radio = SoundEffect .HIGH_END_RADIO in config .effects
150
+ if contains_high_end_radio :
151
+ audio = self ._add_wav_effect (audio , sample_rate , "Radio_Static_Beep.wav" )
152
+
93
153
if config .play_beep :
94
154
audio = self ._add_wav_effect (audio , sample_rate , "beep.wav" )
95
155
elif config .play_beep_apollo :
@@ -130,9 +190,8 @@ async def notify_playback_finished(self, wingman_name: str):
130
190
await self .on_playback_finished (wingman_name )
131
191
132
192
def play_wav (self , audio_sample_file : str ):
133
- bundle_dir = path .abspath (path .dirname (__file__ ))
134
193
beep_audio , beep_sample_rate = self .get_audio_from_file (
135
- path .join (bundle_dir , f"../audio_samples/ { audio_sample_file } " )
194
+ path .join (self . sample_dir , audio_sample_file )
136
195
)
137
196
self .start_playback (beep_audio , beep_sample_rate , 1 , None )
138
197
@@ -147,15 +206,21 @@ def _get_audio_from_stream(self, stream: bytes) -> tuple:
147
206
def _add_wav_effect (
148
207
self , audio : np .ndarray , sample_rate : int , audio_sample_file : str
149
208
) -> np .ndarray :
150
- bundle_dir = path .abspath (path .dirname (__file__ ))
151
209
beep_audio , beep_sample_rate = self .get_audio_from_file (
152
- path .join (bundle_dir , f"../audio_samples/ { audio_sample_file } " )
210
+ path .join (self . sample_dir , audio_sample_file )
153
211
)
154
212
155
213
# Resample the beep sound if necessary to match the sample rate of 'audio'
156
214
if beep_sample_rate != sample_rate :
157
215
beep_audio = self ._resample_audio (beep_audio , beep_sample_rate , sample_rate )
158
216
217
+ # Ensure beep_audio has the same number of channels as 'audio'
218
+ if beep_audio .ndim == 1 and audio .ndim == 2 :
219
+ beep_audio = np .tile (beep_audio [:, np .newaxis ], (1 , audio .shape [1 ]))
220
+
221
+ if beep_audio .ndim == 2 and audio .ndim == 1 :
222
+ audio = audio [:, np .newaxis ]
223
+
159
224
# Concatenate the beep sound to the start and end of the audio
160
225
audio_with_beeps = np .concatenate ((beep_audio , audio , beep_audio ), axis = 0 )
161
226
@@ -174,11 +239,52 @@ def _resample_audio(
174
239
175
240
return resampled_audio
176
241
242
+ def _mix_in_layer (
243
+ self ,
244
+ audio : np .ndarray ,
245
+ sample_rate : int ,
246
+ mix_layer_file : str ,
247
+ mix_layer_gain_boost_db : float = 0.0 ,
248
+ ) -> np .ndarray :
249
+ noise_audio , noise_sample_rate = self .get_audio_from_file (
250
+ path .join (self .sample_dir , mix_layer_file )
251
+ )
252
+
253
+ if noise_sample_rate != sample_rate :
254
+ noise_audio = self ._resample_audio (
255
+ noise_audio , noise_sample_rate , sample_rate
256
+ )
257
+
258
+ # Ensure both audio and noise_audio have compatible shapes for addition
259
+ if noise_audio .ndim == 1 :
260
+ noise_audio = noise_audio [:, None ]
261
+
262
+ if audio .ndim == 1 :
263
+ audio = audio [:, None ]
264
+
265
+ if noise_audio .shape [1 ] != audio .shape [1 ]:
266
+ noise_audio = np .tile (noise_audio , (1 , audio .shape [1 ]))
267
+
268
+ # Ensure noise_audio length matches audio length
269
+ if len (noise_audio ) < len (audio ):
270
+ repeat_count = int (np .ceil (len (audio ) / len (noise_audio )))
271
+ noise_audio = np .tile (noise_audio , (repeat_count , 1 ))[: len (audio )]
272
+
273
+ noise_audio = noise_audio [: len (audio )]
274
+
275
+ # Convert gain boost from dB to amplitude factor
276
+ amplitude_factor = 10 ** (mix_layer_gain_boost_db / 20 )
277
+
278
+ # Apply volume scaling to the mixed-in layer
279
+ audio_with_noise = audio + amplitude_factor * noise_audio
280
+ return audio_with_noise
281
+
177
282
async def stream_with_effects (
178
283
self ,
179
284
buffer_callback ,
180
285
config : SoundConfig ,
181
286
wingman_name : str ,
287
+ mix_layer_gain_boost_db : float = 0.0 ,
182
288
buffer_size = 2048 ,
183
289
sample_rate = 16000 ,
184
290
channels = 1 ,
@@ -188,14 +294,79 @@ async def stream_with_effects(
188
294
buffer = bytearray ()
189
295
stream_finished = False
190
296
data_received = False
297
+ mixed_pos = 0
298
+
299
+ mix_layer_file = None
300
+ for effect in config .effects :
301
+ if not mix_layer_file :
302
+ mix_layer_file = get_additional_layer_file (effect )
303
+ # if we boost the actual audio, we need to boost the mixed layer as well
304
+ if use_gain_boost :
305
+ mix_layer_gain_boost_db += get_azure_workaround_gain_boost (effect )
306
+
307
+ if mix_layer_file :
308
+ noise_audio , noise_sample_rate = self .get_audio_from_file (
309
+ path .join (self .sample_dir , mix_layer_file )
310
+ )
311
+ if noise_sample_rate != sample_rate :
312
+ noise_audio = self ._resample_audio (
313
+ noise_audio , noise_sample_rate , sample_rate
314
+ )
315
+ if channels > 1 and noise_audio .ndim == 1 :
316
+ noise_audio = np .tile (noise_audio [:, None ], (1 , channels ))
317
+ noise_audio = noise_audio .flatten ()
318
+
319
+ def get_mixed_chunk (length ):
320
+ nonlocal mixed_pos , noise_audio
321
+ chunk = np .zeros (length , dtype = np .float32 )
322
+ remaining = length
323
+ while remaining > 0 :
324
+ if mixed_pos >= len (noise_audio ):
325
+ mixed_pos = 0
326
+ end_pos = min (len (noise_audio ), mixed_pos + remaining )
327
+ chunk [
328
+ length - remaining : length - remaining + (end_pos - mixed_pos )
329
+ ] = noise_audio [mixed_pos :end_pos ]
330
+ remaining -= end_pos - mixed_pos
331
+ mixed_pos = end_pos
332
+ return chunk
191
333
192
334
def callback (outdata , frames , time , status ):
193
- nonlocal buffer , stream_finished , data_received
194
-
335
+ nonlocal buffer , stream_finished , data_received , mixed_pos
195
336
if data_received and len (buffer ) == 0 :
196
337
stream_finished = True
197
- outdata [: len (buffer )] = buffer [: len (outdata )]
198
- buffer = buffer [len (outdata ) :]
338
+ outdata [:] = bytes (len (outdata )) # Fill the buffer with zeros
339
+ return
340
+
341
+ if len (buffer ) > 0 :
342
+ num_elements = frames * channels
343
+ byte_size = np .dtype (dtype ).itemsize
344
+ data_chunk = np .frombuffer (
345
+ buffer [: num_elements * byte_size ], dtype = dtype
346
+ ).astype (np .float32 )
347
+
348
+ if len (data_chunk ) < num_elements :
349
+ data_chunk = np .pad (
350
+ data_chunk , (0 , num_elements - len (data_chunk )), "constant"
351
+ )
352
+
353
+ if channels > 1 and data_chunk .ndim == 1 :
354
+ data_chunk = np .tile (data_chunk [:, None ], (1 , channels )).flatten ()
355
+
356
+ data_chunk = data_chunk [: frames * channels ]
357
+
358
+ if mix_layer_file :
359
+ mix_chunk = get_mixed_chunk (len (data_chunk ))
360
+ # Convert gain boost from dB to amplitude factor
361
+ amplitude_factor = 10 ** (mix_layer_gain_boost_db / 20 )
362
+ data_chunk = (
363
+ data_chunk + mix_chunk [: len (data_chunk )] * amplitude_factor
364
+ )
365
+
366
+ data_chunk = data_chunk .flatten ()
367
+ data_chunk_bytes = data_chunk .astype (dtype ).tobytes ()
368
+ outdata [: len (data_chunk_bytes )] = data_chunk_bytes [: len (outdata )]
369
+ buffer = buffer [num_elements * byte_size :]
199
370
200
371
with sd .RawOutputStream (
201
372
samplerate = sample_rate ,
@@ -215,6 +386,10 @@ def callback(outdata, frames, time, status):
215
386
elif config .play_beep_apollo :
216
387
self .play_wav ("Apollo_Beep.wav" )
217
388
389
+ contains_high_end_radio = SoundEffect .HIGH_END_RADIO in config .effects
390
+ if contains_high_end_radio :
391
+ self .play_wav ("Radio_Static_Beep.wav" )
392
+
218
393
self .raw_stream .start ()
219
394
220
395
sound_effects = get_sound_effects (
@@ -232,17 +407,25 @@ def callback(outdata, frames, time, status):
232
407
data_in_numpy , sample_rate , reset = False
233
408
)
234
409
410
+ if mix_layer_file :
411
+ noise_chunk = get_mixed_chunk (len (data_in_numpy ))
412
+ # Convert gain boost from dB to amplitude factor
413
+ amplitude_factor = 10 ** (mix_layer_gain_boost_db / 20 )
414
+ data_in_numpy = data_in_numpy + noise_chunk * amplitude_factor
415
+
235
416
processed_buffer = data_in_numpy .astype (dtype ).tobytes ()
236
417
buffer .extend (processed_buffer )
237
-
238
418
await self .stream_event .publish ("audio" , processed_buffer )
239
-
240
419
filled_size = buffer_callback (audio_buffer )
241
420
242
421
data_received = True
243
422
while not stream_finished :
244
423
sd .sleep (100 )
245
424
425
+ contains_high_end_radio = SoundEffect .HIGH_END_RADIO in config .effects
426
+ if contains_high_end_radio :
427
+ self .play_wav ("Radio_Static_Beep.wav" )
428
+
246
429
if config .play_beep :
247
430
self .play_wav ("beep.wav" )
248
431
elif config .play_beep_apollo :
0 commit comments