11from __future__ import annotations
22
33import atexit
4+ import logging
45import os
56from typing import Generator , Iterator , TYPE_CHECKING
67from contextlib import contextmanager
1617 import miniaudio
1718 import samplerate
1819
20+ logger = logging .getLogger (__name__ )
21+
1922
2023class AudioOut :
2124 output_rate : float = 48000.0 # Hz
@@ -66,24 +69,22 @@ def __init__(
6669 # Diagnostics variables
6770 self ._underruns = 0
6871 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 :
72+ self ._frame_num = 0
73+ self ._csv_enabled = bool (os .environ .get ("GAMBATERM_AUDIO_CSV" ))
74+ self ._diag_fill_min = 1.0
75+ self ._diag_ratio_min = self .nominal_sampling_ratio
76+ self ._diag_ratio_max = self .nominal_sampling_ratio
77+ if self ._csv_enabled :
7478 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 )
79+ atexit .register (self ._dump_csv )
80+ atexit .register (self ._log_summary )
8081
8182 # Controller configuration
8283 self .correction_min = 1 - self .correction_clamp
8384 self .correction_max = 1 + self .correction_clamp
8485
85- # Batch variable-length emulator output to avoid starving the
86- # ring buffer as runFor() sometimes returns partial frames.
86+ # Batch the variable-length emulator audio output, avoids starving the ring buffer, runFor()
87+ # sometimes returns partial frames!
8788 self ._acc_buf : npt .NDArray [np .float32 ] = np .empty ((0 , 2 ), dtype = np .float32 )
8889
8990 # Controller state
@@ -107,17 +108,8 @@ def start(self) -> miniaudio.PlaybackDevice:
107108 device .start (stream )
108109 return device
109110
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" )):
111+ def _dump_csv (self ) -> None :
112+ if diag_csv := os .environ .get ("GAMBATERM_AUDIO_CSV" ):
121113 with open (diag_csv , "w" ) as fout :
122114 fout .write ("frame,input,acc,proc,output,fill\n " )
123115 for _df in self ._diag_frames :
@@ -126,6 +118,19 @@ def _dump_audio_stats(self) -> None:
126118 f"{ _df ['proc' ]} ,{ _df ['output' ]} ,{ _df ['fill' ]:.4f} \n "
127119 )
128120
121+ def _log_summary (self ) -> None :
122+ logger .debug (
123+ "Audio stats: underruns=%d overruns=%d "
124+ "fill_min=%.4f ratio_min=%.8f ratio_max=%.8f "
125+ "ratio_range=%.8f" ,
126+ self ._underruns ,
127+ self ._overruns ,
128+ self ._diag_fill_min ,
129+ self ._diag_ratio_min ,
130+ self ._diag_ratio_max ,
131+ self ._diag_ratio_max - self ._diag_ratio_min ,
132+ )
133+
129134 @property
130135 def fill_fraction (self ) -> float :
131136 # Ring buffer fill ratio (0-1.0)
@@ -162,52 +167,68 @@ def adapt_sample_rate(self) -> None:
162167 self ._diag_track_ratio ()
163168
164169 def _diag_record_skip (self , input_len : int , acc_len : int ) -> None :
165- if not self ._diag_enabled :
166- return
167170 fill = self .fill_fraction
168171 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
172+ frame = self ._frame_num
173+ self ._frame_num += 1
174+ logger .debug (
175+ "skip frame=%d input=%d acc=%d fill=%.4f" , frame , input_len , acc_len , fill
176+ )
177+ if self ._csv_enabled :
178+ self ._diag_frames .append (
179+ {
180+ "frame" : frame ,
181+ "input" : input_len ,
182+ "acc" : acc_len ,
183+ "proc" : 0 ,
184+ "output" : 0 ,
185+ "fill" : fill ,
186+ }
187+ )
178188
179189 def _diag_record_process (
180- self , input_len : int , acc_len : int , output_len : int ,
190+ self ,
191+ input_len : int ,
192+ acc_len : int ,
193+ output_len : int ,
181194 ) -> None :
182- if not self ._diag_enabled :
183- return
184195 fill = self .fill_fraction
185196 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
197+ frame = self ._frame_num
198+ self ._frame_num += 1
199+ logger .debug (
200+ "process frame=%d input=%d acc=%d output=%d fill=%.4f" ,
201+ frame ,
202+ input_len ,
203+ acc_len ,
204+ output_len ,
205+ fill ,
206+ )
207+ if self ._csv_enabled :
208+ self ._diag_frames .append (
209+ {
210+ "frame" : frame ,
211+ "input" : input_len ,
212+ "acc" : acc_len ,
213+ "proc" : 1 ,
214+ "output" : output_len ,
215+ "fill" : fill ,
216+ }
217+ )
195218
196219 def _diag_track_fill (self ) -> None :
197- if not self ._diag_enabled :
198- return
199220 self ._diag_fill_min = min (self ._diag_fill_min , self .fill_fraction )
200221
201222 def _diag_track_ratio (self ) -> None :
202- if not self ._diag_enabled :
203- return
204223 self ._diag_ratio_min = min (self ._diag_ratio_min , self .sampling_ratio )
205224 self ._diag_ratio_max = max (self ._diag_ratio_max , self .sampling_ratio )
206225
207226 def send (self , console : Console , audio : npt .NDArray [np .int16 ]) -> None :
208227 # Scale and remove DC offset
209- scaled = (audio .astype (np .float32 ) * self .audio_volume
210- - console .AUDIO_OFFSET * self .audio_volume )
228+ scaled = (
229+ audio .astype (np .float32 ) * self .audio_volume
230+ - console .AUDIO_OFFSET * self .audio_volume
231+ )
211232
212233 # Accumulate input to batch variable-length emulator frames into consistent chunks for the
213234 # resampler. The emulator's runFor() may produce anywhere from a few hundred to tens of
@@ -246,6 +267,12 @@ def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None:
246267 # Drop excess frames if we're overrun
247268 if frames > space :
248269 self ._overruns += 1
270+ logger .warning (
271+ "Audio overrun: dropping %d of %d frames (fill=%.2f)" ,
272+ frames - space ,
273+ frames ,
274+ self .fill_fraction ,
275+ )
249276 resampled = resampled [:space ]
250277 frames = space
251278
@@ -317,6 +344,12 @@ def _audio_stream(self) -> Generator[bytes, int, None]:
317344 # Log if we're underrunning
318345 if read_size < required_frames :
319346 self ._underruns += 1
347+ logger .warning (
348+ "Audio underrun: requested %d, got %d (fill=%.2f)" ,
349+ required_frames ,
350+ read_size ,
351+ self .fill_fraction ,
352+ )
320353
321354 # Send audio to output and get next required frames
322355 required_frames = yield result .tobytes ()
0 commit comments