feat(dictate): add voice-reactive listening waveform
This commit is contained in:
@@ -1,19 +1,34 @@
|
||||
import io
|
||||
import math
|
||||
import subprocess
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from pi_dictate_service.recorder import PW_RECORD_COMMAND, PwRecorder, RecorderError
|
||||
from pi_dictate_service.recorder import (
|
||||
AUDIO_LEVEL_WINDOW_BYTES,
|
||||
PW_RECORD_COMMAND,
|
||||
PwRecorder,
|
||||
RecorderError,
|
||||
)
|
||||
|
||||
|
||||
class TrackingStream(io.BytesIO):
|
||||
def __init__(self, value: bytes, events: list[str], name: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
value: bytes,
|
||||
events: list[str],
|
||||
name: str,
|
||||
max_read_size: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(value)
|
||||
self._events = events
|
||||
self._name = name
|
||||
self._max_read_size = max_read_size
|
||||
self.eof = threading.Event()
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if self._max_read_size is not None:
|
||||
size = min(size, self._max_read_size)
|
||||
value = super().read(size)
|
||||
if not value and not self.eof.is_set():
|
||||
self._events.append(f"{self._name}-eof")
|
||||
@@ -91,10 +106,11 @@ class PwRecorderTests(unittest.TestCase):
|
||||
stderr_limit: int = 64 * 1024,
|
||||
clock: Clock | None = None,
|
||||
terminate_returncode: int = -15,
|
||||
stdout_read_size: int | None = None,
|
||||
) -> tuple[PwRecorder, FakeProcess, ProcessFactory, list[str]]:
|
||||
events: list[str] = []
|
||||
process = FakeProcess(
|
||||
TrackingStream(pcm, events, "stdout"),
|
||||
TrackingStream(pcm, events, "stdout", stdout_read_size),
|
||||
TrackingStream(stderr, events, "stderr"),
|
||||
returncode=returncode,
|
||||
timeout=timeout,
|
||||
@@ -138,6 +154,56 @@ class PwRecorderTests(unittest.TestCase):
|
||||
self.assertIn("stdout-eof", events)
|
||||
self.assertIn("wait", events)
|
||||
|
||||
def test_tracks_normalized_audio_level_from_pcm_without_exposing_audio(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
pcm=b"\xff\x7f" * (AUDIO_LEVEL_WINDOW_BYTES // 2),
|
||||
max_bytes=AUDIO_LEVEL_WINDOW_BYTES,
|
||||
)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
self.assertGreater(recorder.audio_level, 0.99)
|
||||
self.assertLessEqual(recorder.audio_level, 1.0)
|
||||
recorder.cancel()
|
||||
|
||||
def test_audio_level_accumulates_fragmented_reads_into_100ms_windows(self) -> None:
|
||||
loud_sample = (32767).to_bytes(2, "little", signed=True)
|
||||
pcm = (
|
||||
loud_sample * (AUDIO_LEVEL_WINDOW_BYTES // 4)
|
||||
+ b"\x00" * (AUDIO_LEVEL_WINDOW_BYTES // 2)
|
||||
+ loud_sample * 400
|
||||
)
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
pcm=pcm,
|
||||
max_bytes=len(pcm),
|
||||
stdout_read_size=800,
|
||||
)
|
||||
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
expected_rms = (0.5**0.5) * 32767 / 32768
|
||||
expected_level = (20 * math.log10(expected_rms) + 60) / 60
|
||||
self.assertAlmostEqual(recorder.audio_level, expected_level, places=3)
|
||||
recorder.cancel()
|
||||
|
||||
def test_audio_level_maps_silence_known_rms_decay_and_partial_samples(self) -> None:
|
||||
recorder = PwRecorder()
|
||||
|
||||
recorder._update_audio_level(b"\x00\x00" * 16)
|
||||
self.assertEqual(recorder.audio_level, 0.0)
|
||||
|
||||
amplitude = 3277 # approximately -20 dBFS
|
||||
recorder._update_audio_level(amplitude.to_bytes(2, "little", signed=True) * 16)
|
||||
self.assertAlmostEqual(recorder.audio_level, 2 / 3, places=3)
|
||||
|
||||
recorder._update_audio_level(b"\x00\x00" * 16)
|
||||
self.assertAlmostEqual(recorder.audio_level, (2 / 3) * 0.65, places=3)
|
||||
|
||||
previous = recorder.audio_level
|
||||
recorder._update_audio_level(b"\xff")
|
||||
self.assertEqual(recorder.audio_level, previous)
|
||||
|
||||
def test_accepts_pw_record_code_one_after_intentional_sigterm(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder(terminate_returncode=1)
|
||||
recorder.start()
|
||||
|
||||
Reference in New Issue
Block a user