Skip to content

Commit 68a347d

Browse files
committed
audio: diagnostics and prevent some underruns
- Add GAMBATERM_AUDIO_LOG and GAMBATERM_AUDIO_CSV to report statistics, replacing some 'TODO' items about logging under and overruns. - Adds common 'fill_fraction' property, to determine - Changed to a batch variable-length emulator audio in send(). runFor() may return only a few hundred samples at video-frame boundaries; feeding those directly to the resampler can starve the ring buffer, instead, accumulate until the batch is large enough prevents this. In practicality, an underrun only occurs when the system is under load, I have only induced a starved ring buffer by using too much cpu by encoding kitty graphics in render(). I plan to address this by "banding", but I think this fix can be helpful for lower end computers or when the system is under load?
1 parent f6fbf74 commit 68a347d

1 file changed

Lines changed: 124 additions & 13 deletions

File tree

gambaterm/audio.py

Lines changed: 124 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import atexit
4+
import os
35
from typing import Generator, Iterator, TYPE_CHECKING
46
from contextlib import contextmanager
57
from 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

Comments
 (0)