11from __future__ import annotations
22
33import atexit
4- import logging
5- import os
6- from typing import Any , Generator , Iterator , TYPE_CHECKING
4+ from typing import Generator , Iterator , TYPE_CHECKING
75from contextlib import contextmanager
86from collections import deque
97
108import numpy as np
119import numpy .typing as npt
10+ import structlog
1211
1312from .console import Console
1413
1716 import miniaudio
1817 import samplerate
1918
20- logger = logging . getLogger ( __name__ )
19+ logger = structlog . get_logger ( )
2120
2221
2322class AudioOut :
@@ -55,12 +54,12 @@ def __init__(
5554 self .ring_buffer = np .zeros ((self .ring_size , 2 ), dtype = np .int16 )
5655
5756 # We implement a SPSC (Single Producer Single Consumer) ring buffer,
58- # so we do not need synchronization primitives. The contract is:
59- # - only the producer (the `send` method) can increment the write counter
57+ # so we do not need synchonization primitives. The contract is:
58+ # - only the producer (the `send` method) can incremement the write counter
6059 # - only the consumer (the `_audio_stream` generator) can increment the read counter
6160 # - the read counter can never surpass the write counter
6261 # - both the consumer and producer can read both counters to compute the fill level
63- # Since this fill is not protected by a lock, it represents:
62+ # Since this this fill is not protected by a lock, it represents:
6463 # - a maximum fill level when it's read by the producer
6564 # - a minimum fill level when it's read by the consumer
6665 self .write_counter = 0
@@ -69,24 +68,15 @@ def __init__(
6968 # Diagnostics variables
7069 self ._underruns = 0
7170 self ._overruns = 0
72- self ._frame_num = 0
73- self ._csv_enabled = bool (os .environ .get ("GAMBATERM_AUDIO_CSV" ))
7471 self ._diag_fill_min = 1.0
7572 self ._diag_ratio_min = self .nominal_sampling_ratio
7673 self ._diag_ratio_max = self .nominal_sampling_ratio
77- if self ._csv_enabled :
78- self ._diag_frames : list [dict [str , Any ]] = []
79- atexit .register (self ._dump_csv )
8074 atexit .register (self ._log_summary )
8175
8276 # Controller configuration
8377 self .correction_min = 1 - self .correction_clamp
8478 self .correction_max = 1 + self .correction_clamp
8579
86- # Batch the variable-length emulator audio output, avoids starving the ring buffer, runFor()
87- # sometimes returns partial frames!
88- self ._acc_buf : npt .NDArray [np .float32 ] = np .empty ((0 , 2 ), dtype = np .float32 )
89-
9080 # Controller state
9181 self .last_buffer_levels = deque [float ](maxlen = self .ma_length )
9282 self .moving_average = 0.5
@@ -108,27 +98,15 @@ def start(self) -> miniaudio.PlaybackDevice:
10898 device .start (stream )
10999 return device
110100
111- def _dump_csv (self ) -> None :
112- if diag_csv := os .environ .get ("GAMBATERM_AUDIO_CSV" ):
113- with open (diag_csv , "w" ) as fout :
114- fout .write ("frame,input,acc,proc,output,fill\n " )
115- for _df in self ._diag_frames :
116- fout .write (
117- f"{ _df ['frame' ]} ,{ _df ['input' ]} ,{ _df ['acc' ]} ,"
118- f"{ _df ['proc' ]} ,{ _df ['output' ]} ,{ _df ['fill' ]:.4f} \n "
119- )
120-
121101 def _log_summary (self ) -> None :
122102 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 ,
103+ "audio_summary" ,
104+ underruns = self ._underruns ,
105+ overruns = self ._overruns ,
106+ fill_min = self ._diag_fill_min ,
107+ ratio_min = self ._diag_ratio_min ,
108+ ratio_max = self ._diag_ratio_max ,
109+ ratio_range = self ._diag_ratio_max - self ._diag_ratio_min ,
132110 )
133111
134112 @property
@@ -164,82 +142,16 @@ def adapt_sample_rate(self) -> None:
164142
165143 # Return the adjusted sample rate
166144 self .sampling_ratio = self .nominal_sampling_ratio * correction
167- self ._diag_track_ratio ()
168-
169- def _diag_record_skip (self , input_len : int , acc_len : int ) -> None :
170- fill = self .fill_fraction
171- self ._diag_fill_min = min (self ._diag_fill_min , fill )
172- frame = self ._frame_num
173- self ._frame_num += 1
174- if self ._csv_enabled :
175- self ._diag_frames .append (
176- {
177- "frame" : frame ,
178- "input" : input_len ,
179- "acc" : acc_len ,
180- "proc" : 0 ,
181- "output" : 0 ,
182- "fill" : fill ,
183- }
184- )
185-
186- def _diag_record_process (
187- self ,
188- input_len : int ,
189- acc_len : int ,
190- output_len : int ,
191- ) -> None :
192- fill = self .fill_fraction
193- self ._diag_fill_min = min (self ._diag_fill_min , fill )
194- frame = self ._frame_num
195- self ._frame_num += 1
196- if self ._csv_enabled :
197- self ._diag_frames .append (
198- {
199- "frame" : frame ,
200- "input" : input_len ,
201- "acc" : acc_len ,
202- "proc" : 1 ,
203- "output" : output_len ,
204- "fill" : fill ,
205- }
206- )
207-
208- def _diag_track_fill (self ) -> None :
209- self ._diag_fill_min = min (self ._diag_fill_min , self .fill_fraction )
210-
211- def _diag_track_ratio (self ) -> None :
212145 self ._diag_ratio_min = min (self ._diag_ratio_min , self .sampling_ratio )
213146 self ._diag_ratio_max = max (self ._diag_ratio_max , self .sampling_ratio )
214147
215148 def send (self , console : Console , audio : npt .NDArray [np .int16 ]) -> None :
216- # Scale and remove DC offset
217- scaled = (
149+ # Resample input audio to output rate with speed adjustment
150+ resampled = self . resampler . process (
218151 audio .astype (np .float32 ) * self .audio_volume
219- - console .AUDIO_OFFSET * self .audio_volume
220- )
221-
222- # Accumulate input to batch variable-length emulator frames into consistent chunks for the
223- # resampler. The emulator's runFor() may produce anywhere from a few hundred to tens of
224- # thousands of samples per call; tiny batches could otherwise starve the ring buffer when
225- # under load.
226- self ._acc_buf = np .concatenate ([self ._acc_buf , scaled ])
227- input_len = len (scaled )
228- acc_len = len (self ._acc_buf )
229-
230- # Process when we have enough to produce ~5ms of output at 48 kHz,
231- # or when we have accumulated more than 3 video frames (safety valve).
232- min_output = 240
233- min_input = max (1 , int (min_output / self .sampling_ratio ))
234- max_input = console .TICKS_IN_FRAME * 3
235- if acc_len < min_input and acc_len < max_input :
236- self ._diag_record_skip (input_len , acc_len )
237- return
238-
239- chunk = self ._acc_buf
240- self ._acc_buf = np .empty ((0 , 2 ), dtype = np .float32 )
241- resampled = self .resampler .process (chunk , self .sampling_ratio )
242- resampled = np .clip (resampled , - 32768 , 32767 ).astype (np .int16 )
152+ - console .AUDIO_OFFSET * self .audio_volume ,
153+ self .sampling_ratio ,
154+ ).astype (np .int16 )
243155
244156 # Get the ring buffer
245157 ring_buffer = self .ring_buffer
@@ -257,10 +169,10 @@ def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None:
257169 if frames > space :
258170 self ._overruns += 1
259171 logger .warning (
260- "Audio overrun: dropping %d of %d frames (fill=%.2f) " ,
261- frames - space ,
262- frames ,
263- self .fill_fraction ,
172+ "audio_overrun " ,
173+ dropped = frames - space ,
174+ frames = frames ,
175+ fill = self .fill_fraction ,
264176 )
265177 resampled = resampled [:space ]
266178 frames = space
@@ -283,7 +195,13 @@ def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None:
283195
284196 # Update the write counter
285197 self .write_counter += frames
286- self ._diag_record_process (input_len , acc_len , frames )
198+ self ._diag_fill_min = min (self ._diag_fill_min , self .fill_fraction )
199+ logger .debug (
200+ "audio_frame" ,
201+ input = len (audio ),
202+ output = frames ,
203+ fill = self .fill_fraction ,
204+ )
287205
288206 def _audio_stream (self ) -> Generator [bytes , int , None ]:
289207 # Get the ring buffer
@@ -328,16 +246,16 @@ def _audio_stream(self) -> Generator[bytes, int, None]:
328246
329247 # Update the read counter
330248 self .read_counter += read_size
331- self ._diag_track_fill ( )
249+ self ._diag_fill_min = min ( self . _diag_fill_min , self . fill_fraction )
332250
333251 # Log if we're underrunning
334252 if read_size < required_frames :
335253 self ._underruns += 1
336254 logger .warning (
337- "Audio underrun: requested %d, got %d (fill=%.2f) " ,
338- required_frames ,
339- read_size ,
340- self .fill_fraction ,
255+ "audio_underrun " ,
256+ required = required_frames ,
257+ got = read_size ,
258+ fill = self .fill_fraction ,
341259 )
342260
343261 # Send audio to output and get next required frames
0 commit comments