Add debug recording for wake/stt

This commit is contained in:
Michael Hansen
2024-01-10 13:33:19 -06:00
parent 350eee6cb2
commit f6e397a1b0
5 changed files with 199 additions and 8 deletions
+8
View File
@@ -222,6 +222,9 @@ async def main() -> None:
help="Host address for zeroconf discovery (default: detect)", help="Host address for zeroconf discovery (default: detect)",
) )
# #
parser.add_argument(
"--debug-recording-dir", help="Directory to store audio for debugging"
)
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages") parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args() args = parser.parse_args()
@@ -258,6 +261,10 @@ async def main() -> None:
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args) _LOGGER.debug(args)
if args.debug_recording_dir:
args.debug_recording_dir = Path(args.debug_recording_dir)
_LOGGER.info("Recording audio to %s", args.debug_recording_dir)
wyoming_info = Info( wyoming_info = Info(
satellite=Satellite( satellite=Satellite(
name=args.name, name=args.name,
@@ -320,6 +327,7 @@ async def main() -> None:
tts_stop=split_command(args.tts_stop_command), tts_stop=split_command(args.tts_stop_command),
error=split_command(args.error_command), error=split_command(args.error_command),
), ),
debug_recording_dir=args.debug_recording_dir,
) )
satellite: SatelliteBase satellite: SatelliteBase
+102 -4
View File
@@ -22,7 +22,7 @@ from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
from .settings import SatelliteSettings from .settings import SatelliteSettings
from .utils import multiply_volume, run_event_command, wav_to_events from .utils import DebugAudioWriter, multiply_volume, run_event_command, wav_to_events
from .vad import SileroVad from .vad import SileroVad
from .webrtc import WebRtcAudio from .webrtc import WebRtcAudio
@@ -60,6 +60,19 @@ class SatelliteBase:
self._event_task: Optional[asyncio.Task] = None self._event_task: Optional[asyncio.Task] = None
self._event_queue: "Optional[asyncio.Queue[Event]]" = None self._event_queue: "Optional[asyncio.Queue[Event]]" = None
# Debug audio recording
self.wake_audio_writer: Optional[DebugAudioWriter] = None
self.stt_audio_writer: Optional[DebugAudioWriter] = None
if settings.debug_recording_dir:
self.wake_audio_writer = DebugAudioWriter(
settings.debug_recording_dir,
"wake",
ring_buffer_size=(2 * 16000 * 2 * 1), # last 2 sec
)
self.stt_audio_writer = DebugAudioWriter(
settings.debug_recording_dir, "stt"
)
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
"""True if not stopping/stopped.""" """True if not stopping/stopped."""
@@ -747,7 +760,16 @@ class AlwaysStreamingSatellite(SatelliteBase):
_LOGGER.info("Streaming audio") _LOGGER.info("Streaming audio")
await self._send_run_pipeline() await self._send_run_pipeline()
await self.trigger_streaming_start() await self.trigger_streaming_start()
elif Detection.is_type(event.type):
# Start debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.start()
elif Transcript.is_type(event.type): elif Transcript.is_type(event.type):
# Stop debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
# We're always streaming
_LOGGER.info("Streaming audio") _LOGGER.info("Streaming audio")
# Re-trigger streaming start even though we technically don't stop # Re-trigger streaming start even though we technically don't stop
@@ -764,6 +786,14 @@ class AlwaysStreamingSatellite(SatelliteBase):
# Forward to server # Forward to server
await self.event_to_server(event) await self.event_to_server(event)
# Debug audio recording
if self.stt_audio_writer is not None:
if audio_bytes is None:
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
self.stt_audio_writer.write(audio_bytes)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -801,6 +831,14 @@ class VadStreamingSatellite(SatelliteBase):
if RunSatellite.is_type(event.type): if RunSatellite.is_type(event.type):
_LOGGER.info("Waiting for speech") _LOGGER.info("Waiting for speech")
elif Detection.is_type(event.type):
# Start debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.start()
elif Transcript.is_type(event.type):
# Stop debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
async def event_from_mic( async def event_from_mic(
self, event: Event, audio_bytes: Optional[bytes] = None self, event: Event, audio_bytes: Optional[bytes] = None
@@ -808,6 +846,18 @@ class VadStreamingSatellite(SatelliteBase):
if not AudioChunk.is_type(event.type): if not AudioChunk.is_type(event.type):
return return
# Only unpack chunk once
chunk: Optional[AudioChunk] = None
# Debug audio recording
if self.stt_audio_writer is not None:
if audio_bytes is None:
# Need to unpack
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
self.stt_audio_writer.write(audio_bytes)
if ( if (
self.is_streaming self.is_streaming
and (self.timeout_seconds is not None) and (self.timeout_seconds is not None)
@@ -817,6 +867,10 @@ class VadStreamingSatellite(SatelliteBase):
self.is_streaming = False self.is_streaming = False
self.timeout_seconds = None self.timeout_seconds = None
# Stop debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
# Stop pipeline # Stop pipeline
await self.event_to_server(AudioStop().event()) await self.event_to_server(AudioStop().event())
@@ -825,10 +879,11 @@ class VadStreamingSatellite(SatelliteBase):
if not self.is_streaming: if not self.is_streaming:
# Check VAD # Check VAD
chunk: Optional[AudioChunk] = None
if audio_bytes is None: if audio_bytes is None:
if chunk is None:
# Need to unpack # Need to unpack
chunk = AudioChunk.from_event(event) chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio audio_bytes = chunk.audio
if not self.vad(audio_bytes): if not self.vad(audio_bytes):
@@ -905,26 +960,61 @@ class WakeStreamingSatellite(SatelliteBase):
if settings.vad.enabled: if settings.vad.enabled:
_LOGGER.warning("VAD is enabled but will not be used") _LOGGER.warning("VAD is enabled but will not be used")
# Used for debug audio recording so both wake and stt WAV files have the
# same timestamp.
self._debug_recording_timestamp: Optional[int] = None
async def event_from_server(self, event: Event) -> None: async def event_from_server(self, event: Event) -> None:
if Transcript.is_type(event.type): # Only check event types once
is_run_satellite = False
is_transcript = False
if RunSatellite.is_type(event.type):
is_run_satellite = True
elif Transcript.is_type(event.type):
is_transcript = True
if is_transcript:
# Stop streaming before event_from_server is called because it will # Stop streaming before event_from_server is called because it will
# play the "done" WAV. # play the "done" WAV.
self.is_streaming = False self.is_streaming = False
# Stop debug recording (stt)
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
await super().event_from_server(event) await super().event_from_server(event)
if RunSatellite.is_type(event.type) or Transcript.is_type(event.type): if is_run_satellite or is_transcript:
# Stop streaming and go back to wake word detection
self.is_streaming = False self.is_streaming = False
await self.trigger_streaming_stop() await self.trigger_streaming_stop()
await self._send_wake_detect() await self._send_wake_detect()
_LOGGER.info("Waiting for wake word") _LOGGER.info("Waiting for wake word")
# Start debug recording (wake)
self._debug_recording_timestamp = time.monotonic_ns()
if self.wake_audio_writer is not None:
self.wake_audio_writer.start(timestamp=self._debug_recording_timestamp)
async def event_from_mic( async def event_from_mic(
self, event: Event, audio_bytes: Optional[bytes] = None self, event: Event, audio_bytes: Optional[bytes] = None
) -> None: ) -> None:
if not AudioChunk.is_type(event.type): if not AudioChunk.is_type(event.type):
return return
# Debug audio recording
if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None):
if audio_bytes is None:
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
if self.wake_audio_writer is not None:
self.wake_audio_writer.write(audio_bytes)
if self.stt_audio_writer is not None:
self.stt_audio_writer.write(audio_bytes)
if self.is_streaming: if self.is_streaming:
# Forward to server # Forward to server
await self.event_to_server(event) await self.event_to_server(event)
@@ -947,6 +1037,14 @@ class WakeStreamingSatellite(SatelliteBase):
_LOGGER.debug("Wake word detection occurred during refractory period") _LOGGER.debug("Wake word detection occurred during refractory period")
return return
# Stop debug recording (wake)
if self.wake_audio_writer is not None:
self.wake_audio_writer.stop()
# Start debug recording (stt)
if self.stt_audio_writer is not None:
self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp)
self.is_streaming = True self.is_streaming = True
_LOGGER.debug("Streaming audio") _LOGGER.debug("Streaming audio")
+5
View File
@@ -1,6 +1,7 @@
"""Satellite settings.""" """Satellite settings."""
from abc import ABC from abc import ABC
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -160,4 +161,8 @@ class SatelliteSettings:
wake: WakeSettings = field(default_factory=WakeSettings) wake: WakeSettings = field(default_factory=WakeSettings)
snd: SndSettings = field(default_factory=SndSettings) snd: SndSettings = field(default_factory=SndSettings)
event: EventSettings = field(default_factory=EventSettings) event: EventSettings = field(default_factory=EventSettings)
restart_timeout: float = 5.0 restart_timeout: float = 5.0
debug_recording_dir: Optional[Path] = None
"""Path to directory where debug audio is written."""
+8 -1
View File
@@ -1,5 +1,11 @@
"""Utility methods.""" """Utility methods."""
from .audio import AudioBuffer, chunk_samples, multiply_volume, wav_to_events from .audio import (
AudioBuffer,
DebugAudioWriter,
chunk_samples,
multiply_volume,
wav_to_events,
)
from .misc import ( from .misc import (
get_mac_address, get_mac_address,
needs_silero, needs_silero,
@@ -11,6 +17,7 @@ from .misc import (
__all__ = [ __all__ = [
"AudioBuffer", "AudioBuffer",
"chunk_samples", "chunk_samples",
"DebugAudioWriter",
"get_mac_address", "get_mac_address",
"multiply_volume", "multiply_volume",
"needs_silero", "needs_silero",
+74 -1
View File
@@ -1,10 +1,12 @@
"""Audio utilities.""" """Audio utilities."""
import array import array
import logging import logging
import time
import wave import wave
from pathlib import Path from pathlib import Path
from typing import Iterable, Iterator, Union from typing import Iterable, Iterator, Optional, Union
from pyring_buffer import RingBuffer
from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.event import Event from wyoming.event import Event
@@ -130,3 +132,74 @@ def wav_to_events(
audio_bytes = wav_file.readframes(samples_per_chunk) audio_bytes = wav_file.readframes(samples_per_chunk)
yield AudioStop(timestamp=timestamp).event() yield AudioStop(timestamp=timestamp).event()
class DebugAudioWriter:
def __init__(
self,
dir_path: Union[str, Path],
suffix: str,
rate: int = 16000,
width: int = 2,
channels: int = 1,
ring_buffer_size: Optional[int] = None,
) -> None:
self.dir_path = Path(dir_path)
self.suffix = suffix
self.rate = rate
self.width = width
self.channels = channels
self._wav_path: Optional[Path] = None
self._wav_writer: Optional[wave.Wave_write] = None
# If ring buffer size is set, we will hold audio in a ring buffer
# instead of directly writing it to disk.
#
# This allows only the last few seconds of wake word audio to be stored.
self._ring_buffer: Optional[RingBuffer] = None
if ring_buffer_size is not None:
# Hold audio in a ring buffer before writing
self._ring_buffer = RingBuffer(ring_buffer_size)
def start(self, timestamp: Optional[int] = None) -> None:
self.stop()
if timestamp is None:
timestamp = time.monotonic_ns()
self._wav_path = self.dir_path / f"{timestamp}-{self.suffix}.wav"
self._wav_path.parent.mkdir(parents=True, exist_ok=True)
self._wav_writer = wave.open(str(self._wav_path), "wb")
self._wav_writer.setframerate(self.rate)
self._wav_writer.setsampwidth(self.width)
self._wav_writer.setnchannels(self.channels)
_LOGGER.debug("Started recording to %s", self._wav_path)
def write(self, audio: bytes) -> None:
if self._wav_writer is None:
return
if self._ring_buffer is not None:
# Hold audio in a ring buffer before writing
self._ring_buffer.put(audio)
else:
# Write directly to disk
self._wav_writer.writeframes(audio)
def stop(self) -> None:
if self._wav_writer is None:
return
if self._ring_buffer is not None:
# Write all audio now and reset buffer
self._wav_writer.writeframes(self._ring_buffer.getvalue())
self._ring_buffer = RingBuffer(self._ring_buffer.maxlen)
self._wav_writer.close()
self._wav_writer = None
_LOGGER.debug("Stopped recording to %s", self._wav_path)
self._wav_path = None