Skip to content

Commit 5706f6b

Browse files
committed
remove batching and use structlog
1 parent c93b595 commit 5706f6b

1 file changed

Lines changed: 34 additions & 116 deletions

File tree

gambaterm/audio.py

Lines changed: 34 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
from __future__ import annotations
22

33
import atexit
4-
import logging
5-
import os
6-
from typing import Any, Generator, Iterator, TYPE_CHECKING
4+
from typing import Generator, Iterator, TYPE_CHECKING
75
from contextlib import contextmanager
86
from collections import deque
97

108
import numpy as np
119
import numpy.typing as npt
10+
import structlog
1211

1312
from .console import Console
1413

@@ -17,7 +16,7 @@
1716
import miniaudio
1817
import samplerate
1918

20-
logger = logging.getLogger(__name__)
19+
logger = structlog.get_logger()
2120

2221

2322
class 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

Comments
 (0)