11from __future__ import annotations
22
3+ import atexit
4+ import os
35from typing import Generator , Iterator , TYPE_CHECKING
46from contextlib import contextmanager
57from collections import deque
@@ -50,21 +52,40 @@ def __init__(
5052 self .ring_buffer = np .zeros ((self .ring_size , 2 ), dtype = np .int16 )
5153
5254 # We implement a SPSC (Single Producer Single Consumer) ring buffer,
53- # so we do not need synchonization primitives. The contract is:
54- # - only the producer (the `send` method) can incremement the write counter
55+ # so we do not need synchronization primitives. The contract is:
56+ # - only the producer (the `send` method) can increment the write counter
5557 # - only the consumer (the `_audio_stream` generator) can increment the read counter
5658 # - the read counter can never surpass the write counter
5759 # - both the consumer and producer can read both counters to compute the fill level
58- # Since this this fill is not protected by a lock, it represents:
60+ # Since this fill is not protected by a lock, it represents:
5961 # - a maximum fill level when it's read by the producer
6062 # - a minimum fill level when it's read by the consumer
6163 self .write_counter = 0
6264 self .read_counter = 0
6365
66+ # Diagnostics variables
67+ self ._underruns = 0
68+ self ._overruns = 0
69+ self ._diag_enabled = bool (
70+ os .environ .get ("GAMBATERM_AUDIO_CSV" )
71+ or os .environ .get ("GAMBATERM_AUDIO_LOG" )
72+ )
73+ if self ._diag_enabled :
74+ self ._diag_frames : list [dict ] = []
75+ self ._diag_frame_num = 0
76+ self ._diag_fill_min = 1.0
77+ self ._diag_ratio_min = self .nominal_sampling_ratio
78+ self ._diag_ratio_max = self .nominal_sampling_ratio
79+ atexit .register (self ._dump_audio_stats )
80+
6481 # Controller configuration
6582 self .correction_min = 1 - self .correction_clamp
6683 self .correction_max = 1 + self .correction_clamp
6784
85+ # Batch variable-length emulator output to avoid starving the
86+ # ring buffer as runFor() sometimes returns partial frames.
87+ self ._acc_buf : npt .NDArray [np .float32 ] = np .empty ((0 , 2 ), dtype = np .float32 )
88+
6889 # Controller state
6990 self .last_buffer_levels = deque [float ](maxlen = self .ma_length )
7091 self .moving_average = 0.5
@@ -86,10 +107,35 @@ def start(self) -> miniaudio.PlaybackDevice:
86107 device .start (stream )
87108 return device
88109
110+ def _dump_audio_stats (self ) -> None :
111+ if (audio_log := os .environ .get ("GAMBATERM_AUDIO_LOG" )):
112+ with open (audio_log , "w" ) as fout :
113+ fout .write (f"underruns={ self ._underruns } \n " )
114+ fout .write (f"overruns={ self ._overruns } \n " )
115+ fout .write (f"fill_min={ self ._diag_fill_min :.4f} \n " )
116+ fout .write (f"ratio_min={ self ._diag_ratio_min :.8f} \n " )
117+ fout .write (f"ratio_max={ self ._diag_ratio_max :.8f} \n " )
118+ _rng = self ._diag_ratio_max - self ._diag_ratio_min
119+ fout .write (f"ratio_range={ _rng :.8f} \n " )
120+ if (diag_csv := os .environ .get ("GAMBATERM_AUDIO_CSV" )):
121+ with open (diag_csv , "w" ) as fout :
122+ fout .write ("frame,input,acc,proc,output,fill\n " )
123+ for _df in self ._diag_frames :
124+ fout .write (
125+ f"{ _df ['frame' ]} ,{ _df ['input' ]} ,{ _df ['acc' ]} ,"
126+ f"{ _df ['proc' ]} ,{ _df ['output' ]} ,{ _df ['fill' ]:.4f} \n "
127+ )
128+
129+ @property
130+ def fill_fraction (self ) -> float :
131+ # Ring buffer fill ratio (0-1.0)
132+ if self .ring_size == 0 :
133+ return 0.0
134+ return (self .write_counter - self .read_counter ) / self .ring_size
135+
89136 def adapt_sample_rate (self ) -> None :
90137 # First perform a short moving average of the last 5 measurements
91- ring_fill = self .write_counter - self .read_counter
92- self .last_buffer_levels .append (ring_fill / self .ring_size )
138+ self .last_buffer_levels .append (self .fill_fraction )
93139 buffer_level = sum (self .last_buffer_levels ) / len (self .last_buffer_levels )
94140
95141 # Then perform a longer exponential moving average
@@ -113,13 +159,77 @@ def adapt_sample_rate(self) -> None:
113159
114160 # Return the adjusted sample rate
115161 self .sampling_ratio = self .nominal_sampling_ratio * correction
162+ self ._diag_track_ratio ()
163+
164+ def _diag_record_skip (self , input_len : int , acc_len : int ) -> None :
165+ if not self ._diag_enabled :
166+ return
167+ fill = self .fill_fraction
168+ self ._diag_fill_min = min (self ._diag_fill_min , fill )
169+ self ._diag_frames .append ({
170+ "frame" : self ._diag_frame_num ,
171+ "input" : input_len ,
172+ "acc" : acc_len ,
173+ "proc" : 0 ,
174+ "output" : 0 ,
175+ "fill" : fill ,
176+ })
177+ self ._diag_frame_num += 1
178+
179+ def _diag_record_process (
180+ self , input_len : int , acc_len : int , output_len : int ,
181+ ) -> None :
182+ if not self ._diag_enabled :
183+ return
184+ fill = self .fill_fraction
185+ self ._diag_fill_min = min (self ._diag_fill_min , fill )
186+ self ._diag_frames .append ({
187+ "frame" : self ._diag_frame_num ,
188+ "input" : input_len ,
189+ "acc" : acc_len ,
190+ "proc" : 1 ,
191+ "output" : output_len ,
192+ "fill" : fill ,
193+ })
194+ self ._diag_frame_num += 1
195+
196+ def _diag_track_fill (self ) -> None :
197+ if not self ._diag_enabled :
198+ return
199+ self ._diag_fill_min = min (self ._diag_fill_min , self .fill_fraction )
200+
201+ def _diag_track_ratio (self ) -> None :
202+ if not self ._diag_enabled :
203+ return
204+ self ._diag_ratio_min = min (self ._diag_ratio_min , self .sampling_ratio )
205+ self ._diag_ratio_max = max (self ._diag_ratio_max , self .sampling_ratio )
116206
117207 def send (self , console : Console , audio : npt .NDArray [np .int16 ]) -> None :
118- # Resample input audio to output rate with speed adjustment
119- resampled = self .resampler .process (
120- audio * self .audio_volume - console .AUDIO_OFFSET * self .audio_volume ,
121- self .sampling_ratio ,
122- ).astype (np .int16 )
208+ # Scale and remove DC offset
209+ scaled = (audio .astype (np .float32 ) * self .audio_volume
210+ - console .AUDIO_OFFSET * self .audio_volume )
211+
212+ # Accumulate input to batch variable-length emulator frames into consistent chunks for the
213+ # resampler. The emulator's runFor() may produce anywhere from a few hundred to tens of
214+ # thousands of samples per call; tiny batches could otherwise starve the ring buffer when
215+ # under load.
216+ self ._acc_buf = np .concatenate ([self ._acc_buf , scaled ])
217+ input_len = len (scaled )
218+ acc_len = len (self ._acc_buf )
219+
220+ # Process when we have enough to produce ~5ms of output at 48 kHz,
221+ # or when we have accumulated more than 3 video frames (safety valve).
222+ min_output = 240
223+ min_input = max (1 , int (min_output / self .sampling_ratio ))
224+ max_input = console .TICKS_IN_FRAME * 3
225+ if acc_len < min_input and acc_len < max_input :
226+ self ._diag_record_skip (input_len , acc_len )
227+ return
228+
229+ chunk = self ._acc_buf
230+ self ._acc_buf = np .empty ((0 , 2 ), dtype = np .float32 )
231+ resampled = self .resampler .process (chunk , self .sampling_ratio )
232+ resampled = np .clip (resampled , - 32768 , 32767 ).astype (np .int16 )
123233
124234 # Get the ring buffer
125235 ring_buffer = self .ring_buffer
@@ -135,7 +245,7 @@ def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None:
135245
136246 # Drop excess frames if we're overrun
137247 if frames > space :
138- # TODO: Implement logging
248+ self . _overruns += 1
139249 resampled = resampled [:space ]
140250 frames = space
141251
@@ -157,6 +267,7 @@ def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None:
157267
158268 # Update the write counter
159269 self .write_counter += frames
270+ self ._diag_record_process (input_len , acc_len , frames )
160271
161272 def _audio_stream (self ) -> Generator [bytes , int , None ]:
162273 # Get the ring buffer
@@ -201,11 +312,11 @@ def _audio_stream(self) -> Generator[bytes, int, None]:
201312
202313 # Update the read counter
203314 self .read_counter += read_size
315+ self ._diag_track_fill ()
204316
205317 # Log if we're underrunning
206318 if read_size < required_frames :
207- # TODO: Implement logging
208- pass
319+ self ._underruns += 1
209320
210321 # Send audio to output and get next required frames
211322 required_frames = yield result .tobytes ()
0 commit comments