Skip to content

Commit dc9393d

Browse files
authored
Implement dynamic audio rate control (PR #39, issue #27)
Implement dynamic audio rate control and migrate to miniaudio
2 parents e096307 + c9a78ea commit dc9393d

11 files changed

Lines changed: 335 additions & 135 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ Here is the list of the dependencies used in this project, all great open source
262262
- [Cython](https://cython.org/) - Binding to gambatte C++ API, and fast video frame conversion
263263
- [blessed](https://github.com/jquast/blessed) - Cross-platform terminal handling and kitty keyboard protocol support
264264
- [samplerate](https://github.com/tuxu/python-samplerate) - Resampling the audio stream
265-
- [sounddevice](https://github.com/spatialaudio/python-sounddevice) - Playing the audio stream
265+
- [miniaudio](https://github.com/irmen/pyminiaudio) - Playing the audio stream
266266
- [xlib](https://github.com/python-xlib/python-xlib)/[pynput](https://github.com/moses-palmer/pynput) - Getting keyboard inputs
267267
- [pygame](https://github.com/pygame/pygame) - Getting game controller inputs
268268
- [asyncssh](https://github.com/ronf/asyncssh) - Running the SSH server

gambaterm/audio.py

Lines changed: 235 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from __future__ import annotations
22

3-
from typing import Iterator, TYPE_CHECKING
4-
from queue import Queue, Empty, Full
3+
from typing import Generator, Iterator, TYPE_CHECKING
54
from contextlib import contextmanager
5+
from collections import deque
66

77
import numpy as np
88
import numpy.typing as npt
@@ -11,99 +11,256 @@
1111

1212
# Late import of samplerate
1313
if TYPE_CHECKING:
14+
import miniaudio
1415
import samplerate
1516

1617

1718
class AudioOut:
18-
output_rate: float = 48000.0
19-
buffer_size: int = int(output_rate // 60)
19+
output_rate: float = 48000.0 # Hz
20+
audio_delay: float = 0.100 # seconds
21+
audio_volume: float = 0.25
2022

21-
input_rate: float
22-
speed: float
23-
resampler: samplerate.Resampler
24-
queue: Queue[npt.NDArray[np.int16]]
25-
buffer: npt.NDArray[np.int16]
26-
offset: int
23+
# Controller configuration
24+
kp: float = 0.1
25+
ki: float = 0.001
26+
ma_length: int = 5
27+
ema_alpha: float = 0.1
28+
correction_clamp: float = 0.004
2729

2830
def __init__(
29-
self, input_rate: float, resampler: samplerate.Resampler, speed: float = 1.0
31+
self,
32+
console: Console,
33+
resampler: samplerate.Resampler,
34+
speed: float = 1.0,
3035
):
31-
self.input_rate = input_rate
32-
self.speed = speed
3336
self.resampler = resampler
34-
self.queue = Queue(maxsize=6) # 100 ms delay
35-
self.buffer = np.full((self.buffer_size, 2), 0.0, np.int16)
36-
self.offset = 0
37-
38-
@property
39-
def ratio(self) -> float:
40-
return self.output_rate / self.input_rate / self.speed
41-
42-
def send(self, audio: npt.NDArray[np.int16]) -> None:
43-
# Resample to output rate
44-
data = self.resampler.process(audio, self.ratio)
45-
# Loop over data blocks
37+
input_rate = console.FPS * console.TICKS_IN_FRAME
38+
self.nominal_sampling_ratio = self.output_rate / input_rate / speed
39+
40+
# Ring buffer state
41+
self.ring_size = int(self.output_rate * self.audio_delay * 2)
42+
self.ring_buffer = np.zeros((self.ring_size, 2), dtype=np.int16)
43+
44+
# We implement a SPSC (Single Producer Single Consumer) ring buffer,
45+
# so we do not need synchonization primitives. The contract is:
46+
# - only the producer (the `send` method) can incremement the write counter
47+
# - only the consumer (the `_audio_stream` generator) can increment the read counter
48+
# - the read counter can never surpass the write counter
49+
# - both the consumer and producer can read both counters to compute the fill level
50+
# Since this this fill is not protected by a lock, it represents:
51+
# - a maximum fill level when it's read by the producer
52+
# - a minimum fill level when it's read by the consumer
53+
self.write_counter = 0
54+
self.read_counter = 0
55+
56+
# Controller configuration
57+
self.correction_min = 1 - self.correction_clamp
58+
self.correction_max = 1 + self.correction_clamp
59+
60+
# Controller state
61+
self.last_buffer_levels = deque[float](maxlen=self.ma_length)
62+
self.moving_average = 0.5
63+
self.integral = 0.0
64+
self.sampling_ratio = self.nominal_sampling_ratio
65+
66+
def start(self) -> miniaudio.PlaybackDevice:
67+
# Late import
68+
import miniaudio
69+
70+
stream = self._audio_stream()
71+
next(stream)
72+
device = miniaudio.PlaybackDevice(
73+
output_format=miniaudio.SampleFormat.SIGNED16,
74+
nchannels=2,
75+
sample_rate=int(self.output_rate),
76+
buffersize_msec=int(round(self.audio_delay / 2 * 1000)),
77+
)
78+
device.start(stream)
79+
return device
80+
81+
def update_speed(self, console: Console, speed: float) -> None:
82+
input_rate = console.FPS * console.TICKS_IN_FRAME
83+
self.nominal_sampling_ratio = self.output_rate / input_rate / speed
84+
self.sampling_ratio = self.nominal_sampling_ratio
85+
86+
def adapt_sample_rate(self) -> None:
87+
# First perform a short moving average of the last 5 measurements
88+
ring_fill = self.write_counter - self.read_counter
89+
self.last_buffer_levels.append(ring_fill / self.ring_size)
90+
buffer_level = sum(self.last_buffer_levels) / len(self.last_buffer_levels)
91+
92+
# Then perform a longer exponential moving average
93+
self.moving_average += self.ema_alpha * (buffer_level - self.moving_average)
94+
95+
# Compute the error (the target is 50% full)
96+
error = 0.5 - self.moving_average
97+
98+
# Compute propertional and integral contributions
99+
proportional = self.kp * error
100+
self.integral += self.ki * error
101+
102+
# Compute the correction factor
103+
correction = 1.0 + proportional + self.integral
104+
105+
# Slew / Pitch clamp: Prevent the output from shifting pitch
106+
correction = max(self.correction_min, min(self.correction_max, correction))
107+
108+
# Anti-Windup for the integral
109+
self.integral = correction - 1.0 - proportional
110+
111+
# Return the adjusted sample rate
112+
self.sampling_ratio = self.nominal_sampling_ratio * correction
113+
114+
def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None:
115+
# Resample input audio to output rate with speed adjustment
116+
resampled = self.resampler.process(
117+
audio * self.audio_volume - console.AUDIO_OFFSET * self.audio_volume,
118+
self.sampling_ratio,
119+
).astype(np.int16)
120+
121+
# Get the ring buffer
122+
ring_buffer = self.ring_buffer
123+
ring_size = self.ring_size
124+
125+
# Get the counters
126+
read_counter = self.read_counter
127+
write_counter = self.write_counter
128+
129+
frames = len(resampled)
130+
ring_fill = write_counter - read_counter
131+
space = ring_size - ring_fill
132+
133+
# Drop excess frames if we're overrun
134+
if frames > space:
135+
# TODO: Implement logging
136+
resampled = resampled[:space]
137+
frames = space
138+
139+
# Write audio to ring buffer with wrap-around
140+
start_write_pos = write_counter % ring_size
141+
stop_write_pos = (start_write_pos + frames) % ring_size
142+
143+
# Single write (no wrap around)
144+
if stop_write_pos >= start_write_pos:
145+
ring_buffer[start_write_pos:stop_write_pos] = resampled
146+
147+
# Wrap around the ring buffer
148+
else:
149+
first_part = ring_size - start_write_pos
150+
ring_buffer[start_write_pos:] = resampled[:first_part]
151+
ring_buffer[:stop_write_pos] = resampled[
152+
first_part : first_part + stop_write_pos
153+
]
154+
155+
# Update the write counter
156+
self.write_counter += frames
157+
158+
def _audio_stream(self) -> Generator[bytes, int, None]:
159+
# Get the ring buffer
160+
ring_buffer = self.ring_buffer
161+
ring_size = self.ring_size
162+
163+
# Get first required frames
164+
required_frames = yield b""
165+
result = np.zeros((required_frames, 2), dtype=np.int16)
166+
167+
# Wait until we have enough frames to fill the first request
168+
while self.write_counter < self.ring_size * 0.375:
169+
required_frames = yield result.tobytes()
170+
171+
# Loop over audio requests
46172
while True:
47-
# Write the current buffer
48-
stop = min(self.buffer_size, self.offset + len(data))
49-
self.buffer[self.offset : stop] = data[: stop - self.offset]
50-
# Current buffer is not complete
51-
if stop != self.buffer_size:
52-
self.offset = stop
53-
return
54-
# Current buffer is complete, decrease volume
55-
self.buffer //= 4
56-
# Send without blocking if possible
57-
try:
58-
self.queue.put_nowait(self.buffer)
59-
# Synchronization issue, let it regulate itself
60-
except Full:
173+
# Adapt sample rate
174+
self.adapt_sample_rate()
175+
176+
# Prepare output buffer
177+
result = np.zeros((required_frames, 2), dtype=np.int16)
178+
179+
# Read the counters
180+
read_counter = self.read_counter
181+
write_counter = self.write_counter
182+
183+
# Compute read position
184+
ring_fill = write_counter - read_counter
185+
read_size = min(ring_fill, required_frames)
186+
start_read_pos = read_counter % ring_size
187+
stop_read_pos = (start_read_pos + read_size) % ring_size
188+
189+
# Single read (no wrap around)
190+
if stop_read_pos >= start_read_pos:
191+
result[:read_size] = ring_buffer[start_read_pos:stop_read_pos]
192+
# Wrap around the ring buffer
193+
else:
194+
result[: ring_size - start_read_pos] = ring_buffer[start_read_pos:]
195+
result[ring_size - start_read_pos : read_size] = ring_buffer[
196+
:stop_read_pos
197+
]
198+
199+
# Update the read counter
200+
self.read_counter += read_size
201+
202+
# Log if we're underrunning
203+
if read_size < required_frames:
204+
# TODO: Implement logging
61205
pass
62-
# Create new buffer
63-
self.buffer = np.full((self.buffer_size, 2), 0.0, np.int16)
64-
# Process remaining data
65-
data = data[stop - self.offset :]
66-
self.offset = 0
67206

68-
def stream_callback(self, output_buffer: npt.NDArray[np.int16], *_: object) -> None:
69-
try:
70-
output_buffer[:] = self.queue.get_nowait()
71-
except Empty:
72-
output_buffer.fill(0)
207+
# Send audio to output and get next required frames
208+
required_frames = yield result.tobytes()
73209

74210

75-
@contextmanager
76-
def audio_player(console: Console, speed: float = 1.0) -> Iterator[AudioOut | None]:
77-
# Perform late imports
78-
import samplerate
211+
class MaybeAudioOut:
212+
def __init__(self, disable_audio: bool = False):
213+
self.disable_audio = disable_audio
214+
self.audio_out: AudioOut | None = None
215+
self.device: miniaudio.PlaybackDevice | None = None
79216

80-
# Especially for sounddevice, as it doesn't package the portaudio library in its manylinux wheels.
81-
try:
82-
import sounddevice
83-
except OSError:
84-
raise SystemExit(
85-
"""\
86-
Audio output is not available because the PortAudio library could not be found.
87-
Please make sure you have portaudio installed.
88-
For example, on Debian-based distributions, you can run:
89-
$ sudo apt install libportaudio2
90-
Otherswise, you can use the --no-audio option to run without audio support."""
217+
def stop(self) -> None:
218+
if self.device is not None:
219+
self.device.stop()
220+
self.device = None
221+
self.audio_out = None
222+
223+
def update_speed(self, console: Console, speed: float) -> None:
224+
# Ignore if audio is disabled
225+
if self.disable_audio:
226+
return
227+
228+
# Speed not supported, disable audio
229+
if not (0.499 < speed < 2.001):
230+
self.stop()
231+
return
232+
233+
# Adjust speed if audio is enabled
234+
if self.audio_out is not None:
235+
self.audio_out.update_speed(console, speed)
236+
return
237+
238+
# Late import
239+
import samplerate
240+
241+
# Speed supported, enable audio
242+
self.audio_out = AudioOut(
243+
console,
244+
resampler=samplerate.Resampler("linear", channels=2),
245+
speed=speed,
91246
)
247+
self.device = self.audio_out.start()
92248

93-
input_rate = console.FPS * console.TICKS_IN_FRAME
94-
resampler = samplerate.Resampler("linear", channels=2)
95-
audio_out = AudioOut(input_rate, resampler, speed)
96-
with sounddevice.OutputStream(
97-
samplerate=audio_out.output_rate,
98-
dtype="int16",
99-
channels=2,
100-
latency="low",
101-
blocksize=audio_out.buffer_size,
102-
callback=audio_out.stream_callback,
103-
):
104-
yield audio_out
249+
def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None:
250+
if self.audio_out is not None:
251+
self.audio_out.send(console, audio)
105252

106253

107254
@contextmanager
108-
def no_audio(console: Console, speed: float = 1.0) -> Iterator[AudioOut | None]:
109-
yield None
255+
def audio_player(
256+
console: Console, speed: float = 1.0, disable_audio: bool = False
257+
) -> Iterator[MaybeAudioOut]:
258+
maybe_audio_out = MaybeAudioOut(disable_audio=disable_audio)
259+
maybe_audio_out.update_speed(console, speed)
260+
try:
261+
yield maybe_audio_out
262+
finally:
263+
maybe_audio_out.stop()
264+
265+
266+
DISABLED_AUDIO_OUT = MaybeAudioOut(disable_audio=True)

gambaterm/console.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Console:
1717
HEIGHT: int = NotImplemented
1818
FPS: float = NotImplemented
1919
TICKS_IN_FRAME: int = NotImplemented
20+
AUDIO_OFFSET: int = 0
2021

2122
class Input(IntEnum):
2223
A = 0x01
@@ -101,6 +102,7 @@ class GameboyColor(Console):
101102
HEIGHT: int = 144
102103
FPS: float = 59.727500569606
103104
TICKS_IN_FRAME: int = 35112
105+
AUDIO_OFFSET: int = -0x1E00
104106

105107
gb: GB
106108
force_gameboy: bool

0 commit comments

Comments
 (0)