|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | | -from typing import Iterator, TYPE_CHECKING |
4 | | -from queue import Queue, Empty, Full |
| 3 | +from typing import Generator, Iterator, TYPE_CHECKING |
5 | 4 | from contextlib import contextmanager |
| 5 | +from collections import deque |
6 | 6 |
|
7 | 7 | import numpy as np |
8 | 8 | import numpy.typing as npt |
|
11 | 11 |
|
12 | 12 | # Late import of samplerate |
13 | 13 | if TYPE_CHECKING: |
| 14 | + import miniaudio |
14 | 15 | import samplerate |
15 | 16 |
|
16 | 17 |
|
17 | 18 | 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 |
20 | 22 |
|
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 |
27 | 29 |
|
28 | 30 | 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, |
30 | 35 | ): |
31 | | - self.input_rate = input_rate |
32 | | - self.speed = speed |
33 | 36 | 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 |
46 | 172 | 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 |
61 | 205 | 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 |
67 | 206 |
|
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() |
73 | 209 |
|
74 | 210 |
|
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 |
79 | 216 |
|
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, |
91 | 246 | ) |
| 247 | + self.device = self.audio_out.start() |
92 | 248 |
|
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) |
105 | 252 |
|
106 | 253 |
|
107 | 254 | @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) |
0 commit comments