22import wave
33import numpy as np
44from collections import deque
5- from typing import List , Dict
65import os
76import sys
87
98
9+ class _Voice :
10+ """Lightweight voice object for the audio callback hot path.
11+
12+ Uses __slots__ for fast attribute access — dict key hashing is
13+ measurably slower when called thousands of times per second.
14+ """
15+
16+ __slots__ = ("data" , "idx" )
17+
18+ def __init__ (self , data : np .ndarray ):
19+ self .data = data
20+ self .idx = 0
21+
22+
1023class AudioPlayer :
1124 RATE = 44100
1225 CHANNELS = 2
13- BLOCKSIZE = 256 # ~5.8ms at 44100 Hz
26+ MAX_VOICES = 64 # Drop oldest voices beyond this limit
27+
28+ # Absolute ceiling: ~33ms at 44100 Hz. Keeps latency bounded
29+ # even if the caller passes a huge value.
30+ _MAX_BLOCKSIZE = 1456
31+
32+ def __init__ (self , blocksize : int = 256 ):
33+ self .blocksize = min (blocksize , self ._MAX_BLOCKSIZE )
1434
15- def __init__ (self ):
1635 # Lock-free pending queue: play_data() appends here,
1736 # callback drains into its own local list each cycle.
18- self ._pending : deque = deque ()
19- self ._voices : List [ Dict ] = []
37+ self ._pending : deque [ _Voice ] = deque ()
38+ self ._voices : list [ _Voice ] = []
2039
2140 self .stream = sd .OutputStream (
2241 samplerate = self .RATE ,
23- blocksize = self .BLOCKSIZE ,
42+ blocksize = self .blocksize ,
2443 channels = self .CHANNELS ,
2544 dtype = "float32" ,
2645 latency = "low" ,
@@ -36,7 +55,7 @@ def play_data(self, data: np.ndarray):
3655 if data is None or len (data ) == 0 :
3756 return
3857 # deque.append is atomic in CPython — no lock needed
39- self ._pending .append ({ " data" : data , "idx" : 0 } )
58+ self ._pending .append (_Voice ( data ) )
4059
4160 def play_wave_file (self , file_path : str ):
4261 """Loads and plays a wav file immediately."""
@@ -56,33 +75,35 @@ def _callback(self, outdata: np.ndarray, frames: int, time, status):
5675 print (f"Audio status: { status } " , file = sys .stderr )
5776
5877 # Drain pending voices into our local list (lock-free reads)
59- while True :
60- try :
61- voice = self ._pending .popleft ()
62- self ._voices .append (voice )
63- except IndexError :
64- break
78+ pending = self ._pending
79+ voices = self ._voices
80+ while pending :
81+ voices .append (pending .popleft ())
82+
83+ # Enforce voice cap — drop oldest voices first
84+ if len (voices ) > self .MAX_VOICES :
85+ del voices [: len (voices ) - self .MAX_VOICES ]
6586
6687 # Zero the output buffer
6788 outdata [:] = 0.0
6889
6990 # Mix active voices
70- i = len (self . _voices ) - 1
91+ i = len (voices ) - 1
7192 while i >= 0 :
72- voice = self . _voices [i ]
73- data = voice [ " data" ]
74- idx = voice [ " idx" ]
93+ voice = voices [i ]
94+ data = voice . data
95+ idx = voice . idx
7596
7697 remaining = len (data ) - idx
7798 to_read = min (frames , remaining )
7899
79100 if to_read > 0 :
80101 outdata [:to_read ] += data [idx : idx + to_read ]
81- voice [ " idx" ] += to_read
102+ voice . idx += to_read
82103
83104 # Remove finished voices
84- if voice [ " idx" ] >= len (data ):
85- self . _voices .pop (i )
105+ if voice . idx >= len (data ):
106+ voices .pop (i )
86107
87108 i -= 1
88109
0 commit comments