|
7 | 7 | import os |
8 | 8 | import signal |
9 | 9 | import subprocess |
| 10 | +import time |
10 | 11 | import wave |
11 | 12 | from collections import deque |
12 | 13 | from dataclasses import dataclass |
|
25 | 26 | OUTPUT_FRAME_SAMPLES = 480 # 10 ms at 48 kHz |
26 | 27 | PRE_CONNECT_AUDIO_BUFFER_TOPIC = "lk.agent.pre-connect-audio-buffer" |
27 | 28 | DEFAULT_WM8960_DEVICE_NAME = "wm8960" |
| 29 | +MIC_VU_INTERVAL_SECONDS = 0.5 |
| 30 | +MIC_VU_BAR_WIDTH = 24 |
| 31 | +MIC_VU_FLOOR_DBFS = -60.0 |
28 | 32 |
|
29 | 33 | logger = logging.getLogger("whisplay-wakeword-client") |
30 | 34 |
|
@@ -213,6 +217,61 @@ class MicChunk: |
213 | 217 | samples_per_channel: int |
214 | 218 |
|
215 | 219 |
|
| 220 | +class MicVuMeter: |
| 221 | + def __init__(self) -> None: |
| 222 | + self._sum_squares = 0.0 |
| 223 | + self._sample_count = 0 |
| 224 | + self._peak = 0.0 |
| 225 | + self._next_log_at = time.monotonic() + MIC_VU_INTERVAL_SECONDS |
| 226 | + |
| 227 | + def add(self, chunk: MicChunk) -> None: |
| 228 | + samples = np.frombuffer(chunk.data, dtype=np.int16).astype(np.float32) |
| 229 | + if samples.size == 0: |
| 230 | + return |
| 231 | + |
| 232 | + self._sum_squares += float(np.dot(samples, samples)) |
| 233 | + self._sample_count += int(samples.size) |
| 234 | + self._peak = max(self._peak, float(np.max(np.abs(samples)))) |
| 235 | + |
| 236 | + now = time.monotonic() |
| 237 | + if now < self._next_log_at: |
| 238 | + return |
| 239 | + |
| 240 | + self._log_level() |
| 241 | + self._sum_squares = 0.0 |
| 242 | + self._sample_count = 0 |
| 243 | + self._peak = 0.0 |
| 244 | + self._next_log_at = now + MIC_VU_INTERVAL_SECONDS |
| 245 | + |
| 246 | + def _log_level(self) -> None: |
| 247 | + if self._sample_count == 0: |
| 248 | + return |
| 249 | + |
| 250 | + rms = float(np.sqrt(self._sum_squares / self._sample_count)) |
| 251 | + rms_dbfs = _amplitude_to_dbfs(rms) |
| 252 | + peak_dbfs = _amplitude_to_dbfs(self._peak) |
| 253 | + fill = _dbfs_to_bar_fill(rms_dbfs) |
| 254 | + bar = "#" * fill + "-" * (MIC_VU_BAR_WIDTH - fill) |
| 255 | + logger.info( |
| 256 | + "mic VU [%s] rms=%5.1f dBFS peak=%5.1f dBFS", |
| 257 | + bar, |
| 258 | + rms_dbfs, |
| 259 | + peak_dbfs, |
| 260 | + ) |
| 261 | + |
| 262 | + |
| 263 | +def _amplitude_to_dbfs(amplitude: float) -> float: |
| 264 | + if amplitude <= 0: |
| 265 | + return MIC_VU_FLOOR_DBFS |
| 266 | + |
| 267 | + return float(max(MIC_VU_FLOOR_DBFS, 20.0 * np.log10(amplitude / 32768.0))) |
| 268 | + |
| 269 | + |
| 270 | +def _dbfs_to_bar_fill(dbfs: float) -> int: |
| 271 | + normalized = (dbfs - MIC_VU_FLOOR_DBFS) / abs(MIC_VU_FLOOR_DBFS) |
| 272 | + return round(max(0.0, min(1.0, normalized)) * MIC_VU_BAR_WIDTH) |
| 273 | + |
| 274 | + |
216 | 275 | class PreconnectAudioBuffer: |
217 | 276 | def __init__(self, max_duration: float) -> None: |
218 | 277 | self._max_samples = self._duration_to_samples(max_duration) |
@@ -549,6 +608,7 @@ async def _run_audio_loop( |
549 | 608 | preconnect_buffer = PreconnectAudioBuffer(config.wakeword_preroll_seconds) |
550 | 609 | wakeword_window = WakeWordAudioWindow(WAKEWORD_WINDOW_SAMPLES) |
551 | 610 | wakeword_resampler = rtc.AudioResampler(SAMPLE_RATE, WAKEWORD_SAMPLE_RATE) |
| 611 | + mic_vu_meter = MicVuMeter() |
552 | 612 | state = ClientState() |
553 | 613 |
|
554 | 614 | def _reset_to_idle(reason: str, *, cancel_dispatch: bool = True) -> None: |
@@ -636,6 +696,8 @@ def _on_dispatch_done(task: asyncio.Task[str]) -> None: |
636 | 696 | logger.info("audio loop stopping (shutdown requested)") |
637 | 697 | break |
638 | 698 |
|
| 699 | + mic_vu_meter.add(chunk) |
| 700 | + |
639 | 701 | if state.mode in (ClientMode.IDLE, ClientMode.DISPATCHING): |
640 | 702 | preconnect_buffer.append(chunk) |
641 | 703 |
|
|
0 commit comments