Almost finished

This commit is contained in:
Michael Hansen
2023-12-10 22:43:33 -06:00
parent 22018e6b05
commit 153d27f972
6 changed files with 222 additions and 51 deletions
+3 -2
View File
@@ -117,8 +117,8 @@ Run the satellite with automatic gain control and noise suppression:
``` sh ``` sh
script/run \ script/run \
... \ ... \
--auto-gain 5 \ --mic-auto-gain 5 \
--noise-suppression 2 --mic-noise-suppression 2
``` ```
Automatic gain control is between 0-31, which 31 being the loudest. Automatic gain control is between 0-31, which 31 being the loudest.
@@ -133,6 +133,7 @@ Satellites can respond to events from the server by running commands:
* `--startup-command` - run when satellite starts * `--startup-command` - run when satellite starts
* `--detect-command` - wake word detection has started, but not detected yet (no stdin) * `--detect-command` - wake word detection has started, but not detected yet (no stdin)
* `--streaming-start-command` - audio has started streaming to server * `--streaming-start-command` - audio has started streaming to server
* `--streaming-stop-command` - audio has stopped streaming to server
* `--detection-command` - wake word is detected (wake word name on stdin) * `--detection-command` - wake word is detected (wake word name on stdin)
* `--transcript-command` - speech-to-text transcript is returned (text on stdin) * `--transcript-command` - speech-to-text transcript is returned (text on stdin)
* `--stt-start-command` - user started speaking (no stdin) * `--stt-start-command` - user started speaking (no stdin)
+49 -35
View File
@@ -11,7 +11,7 @@ import wave
from enum import Enum from enum import Enum
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Callable, Final, Iterator, Optional, Union from typing import Callable, Final, Iterator, List, Optional, Union
from pyring_buffer import RingBuffer from pyring_buffer import RingBuffer
from wyoming.asr import Transcribe, Transcript from wyoming.asr import Transcribe, Transcript
@@ -48,6 +48,8 @@ from .utils import AudioBuffer, chunk_samples, multiply_volume, run_event_comman
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
_DIR = Path(__file__).parent _DIR = Path(__file__).parent
# TODO: events service
async def main() -> None: async def main() -> None:
"""Main entry point.""" """Main entry point."""
@@ -81,6 +83,10 @@ async def main() -> None:
help="Sample per chunk for mic-command (default: 1024)", help="Sample per chunk for mic-command (default: 1024)",
) )
parser.add_argument("--mic-volume-multiplier", type=float, default=1.0) parser.add_argument("--mic-volume-multiplier", type=float, default=1.0)
parser.add_argument(
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
)
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
# Sound output # Sound output
parser.add_argument("--snd-uri", help="URI of Wyoming sound service") parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
@@ -147,12 +153,6 @@ async def main() -> None:
help="Seconds before going back to waiting for speech when wake word isn't detected", help="Seconds before going back to waiting for speech when wake word isn't detected",
) )
# Audio enhancement (requires webrtc-noise-audio)
parser.add_argument(
"--noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
)
parser.add_argument("--auto-gain", type=int, default=0, choices=list(range(32)))
# External event handlers # External event handlers
parser.add_argument( parser.add_argument(
"--event-uri", help="URI of Wyoming service to forward events to" "--event-uri", help="URI of Wyoming service to forward events to"
@@ -194,6 +194,10 @@ async def main() -> None:
"--streaming-start-command", "--streaming-start-command",
help="Command to run when audio streaming starts", help="Command to run when audio streaming starts",
) )
parser.add_argument(
"--streaming-stop-command",
help="Command to run when audio streaming stops",
)
parser.add_argument( parser.add_argument(
"--error-command", "--error-command",
help="Command to run when an error occurs", help="Command to run when an error occurs",
@@ -272,7 +276,17 @@ async def main() -> None:
) )
settings = SatelliteSettings( settings = SatelliteSettings(
mic=MicSettings(uri=args.mic_uri, command=_split(args.mic_command)), mic=MicSettings(
uri=args.mic_uri,
command=_split(args.mic_command),
rate=args.mic_command_rate,
width=args.mic_command_width,
channels=args.mic_command_channels,
samples_per_chunk=args.mic_command_samples_per_chunk,
volume_multiplier=args.mic_volume_multiplier,
auto_gain=args.mic_auto_gain,
noise_suppression=args.mic_noise_suppression,
),
vad=VadSettings( vad=VadSettings(
enabled=args.vad, enabled=args.vad,
threshold=args.vad_threshold, threshold=args.vad_threshold,
@@ -285,19 +299,43 @@ async def main() -> None:
command=_split(args.wake_command), command=_split(args.wake_command),
names=args.wake_word_name, names=args.wake_word_name,
), ),
snd=SndSettings(uri=args.snd_uri, command=_split(args.snd_command)), snd=SndSettings(
uri=args.snd_uri,
command=_split(args.snd_command),
rate=args.snd_command_rate,
width=args.snd_command_width,
channels=args.snd_command_channels,
volume_multiplier=args.snd_volume_multiplier,
awake_wav=args.awake_wav,
done_wav=args.done_wav,
),
event=EventSettings( event=EventSettings(
uri=args.event_uri, streaming_start=_split(args.streaming_start_command) uri=args.event_uri,
startup=_split(args.startup_command),
streaming_start=_split(args.streaming_start_command),
streaming_stop=_split(args.streaming_stop_command),
detect=_split(args.detect_command),
detection=_split(args.detection_command),
transcript=_split(args.transcript_command),
stt_start=_split(args.stt_start_command),
stt_stop=_split(args.stt_stop_command),
synthesize=_split(args.synthesize_command),
tts_start=_split(args.tts_start_command),
tts_stop=_split(args.tts_stop_command),
error=_split(args.error_command),
), ),
) )
satellite: SatelliteBase satellite: SatelliteBase
if settings.wake.enabled: if settings.wake.enabled:
# Local wake word detection
satellite = WakeStreamingSatellite(settings) satellite = WakeStreamingSatellite(settings)
elif settings.vad.enabled: elif settings.vad.enabled:
# Stream after speech
satellite = VadStreamingSatellite(settings) satellite = VadStreamingSatellite(settings)
else: else:
# Stream all the time
satellite = AlwaysStreamingSatellite(settings) satellite = AlwaysStreamingSatellite(settings)
if args.startup_command: if args.startup_command:
@@ -949,7 +987,7 @@ def get_mac_address() -> str:
def needs_webrtc(args: argparse.Namespace) -> bool: def needs_webrtc(args: argparse.Namespace) -> bool:
"""Return True if webrtc must be used.""" """Return True if webrtc must be used."""
return (args.noise_suppression > 0) or (args.auto_gain > 0) return (args.mic_noise_suppression > 0) or (args.mic_auto_gain > 0)
def needs_silero(args: argparse.Namespace) -> bool: def needs_silero(args: argparse.Namespace) -> bool:
@@ -964,30 +1002,6 @@ def _split(command: Optional[str]) -> Optional[List[str]]:
return shlex.split(command) return shlex.split(command)
class WebRtcAudio:
"""Audio processing using webrtc."""
_sub_chunk_samples: Final = 160 # 10ms @ 16Khz
_sub_chunk_bytes: Final = _sub_chunk_samples * 2 # 16-bit
def __init__(self, auto_gain: int, noise_suppression: int) -> None:
from webrtc_noise_gain import AudioProcessor
self.audio_processor = AudioProcessor(auto_gain, noise_suppression)
self.audio_buffer = AudioBuffer(self._sub_chunk_bytes)
def __call__(self, audio_bytes: bytes) -> bytes:
"""Process in 10ms chunks."""
clean_chunk = bytes()
for sub_chunk in chunk_samples(
audio_bytes, self._sub_chunk_bytes, self.audio_buffer
):
result = self.audio_processor.Process10ms(sub_chunk)
clean_chunk += result.audio
return clean_chunk
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
+89 -12
View File
@@ -5,7 +5,8 @@ import math
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, auto from enum import Enum, auto
from typing import List, Optional, Set from pathlib import Path
from typing import Callable, List, Optional, Set, Union
from pyring_buffer import RingBuffer from pyring_buffer import RingBuffer
from wyoming.asr import Transcript from wyoming.asr import Transcript
@@ -19,8 +20,9 @@ from wyoming.snd import SndProcessAsyncClient
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 run_event_command from .utils import multiply_volume, run_event_command, wav_to_events
from .vad import SileroVad from .vad import SileroVad
from .webrtc import WebRtcAudio
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
@@ -46,6 +48,7 @@ class SatelliteBase:
self._writer: Optional[asyncio.StreamWriter] = None self._writer: Optional[asyncio.StreamWriter] = None
self._mic_task: Optional[asyncio.Task] = None self._mic_task: Optional[asyncio.Task] = None
self._mic_webrtc: Optional[Callable[[bytes], bytes]] = None
self._snd_task: Optional[asyncio.Task] = None self._snd_task: Optional[asyncio.Task] = None
self._snd_queue: "Optional[asyncio.Queue[Event]]" = None self._snd_queue: "Optional[asyncio.Queue[Event]]" = None
self._wake_task: Optional[asyncio.Task] = None self._wake_task: Optional[asyncio.Task] = None
@@ -229,6 +232,13 @@ class SatelliteBase:
async def _mic_task_proc(self) -> None: async def _mic_task_proc(self) -> None:
mic_client: Optional[AsyncClient] = None mic_client: Optional[AsyncClient] = None
audio_bytes: Optional[bytes] = None
if self.settings.mic.needs_webrtc and (self._mic_webrtc is None):
_LOGGER.debug("Using webrtc audio enhancements")
self._mic_webrtc = WebRtcAudio(
self.settings.mic.auto_gain, self.settings.mic.noise_suppression
)
async def _disconnect() -> None: async def _disconnect() -> None:
try: try:
@@ -253,9 +263,22 @@ class SatelliteBase:
await asyncio.sleep(self.settings.mic.reconnect_seconds) await asyncio.sleep(self.settings.mic.reconnect_seconds)
continue continue
# TODO: audio processing # Audio processing
if self.settings.mic.needs_processing and AudioChunk.is_type(
event.type
):
chunk = AudioChunk.from_event(event)
audio_bytes = self._process_mic_audio(chunk.audio)
event = AudioChunk(
rate=chunk.rate,
width=chunk.width,
channels=chunk.channels,
audio=audio_bytes,
).event()
else:
audio_bytes = None
await self.event_from_mic(event) await self.event_from_mic(event, audio_bytes)
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception: except Exception:
@@ -266,6 +289,17 @@ class SatelliteBase:
await _disconnect() await _disconnect()
def _process_mic_audio(self, audio_bytes: bytes) -> bytes:
if self.settings.mic.volume_multiplier != 1.0:
audio_bytes = multiply_volume(
audio_bytes, self.settings.mic.volume_multiplier
)
if self._mic_webrtc is not None:
audio_bytes = self._mic_webrtc(audio_bytes)
return audio_bytes
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Sound # Sound
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -323,6 +357,18 @@ class SatelliteBase:
await _disconnect() await _disconnect()
async def _play_wav(self, wav_path: Optional[Union[str, Path]]) -> None:
"""Send WAV as events to sound service."""
if (not wav_path) or (not self.settings.snd.enabled):
return
for event in wav_to_events(
wav_path,
samples_per_chunk=self.settings.snd.samples_per_chunk,
volume_multiplier=self.settings.snd.volume_multiplier,
):
await self.event_to_snd(event)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Wake # Wake
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -430,16 +476,28 @@ class SatelliteBase:
async def _send_wake_detect(self) -> None: async def _send_wake_detect(self) -> None:
"""Inform wake word service of which wake words to detect.""" """Inform wake word service of which wake words to detect."""
await self.event_to_wake(Detect(names=self.settings.wake.names).event()) await self.event_to_wake(Detect(names=self.settings.wake.names).event())
await self.trigger_detect()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Events # Events
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def event_streaming_start(self) -> None: async def trigger_streaming_start(self) -> None:
await run_event_command(self.settings.event.streaming_start) await run_event_command(self.settings.event.streaming_start)
async def event_streaming_stop(self) -> None: async def trigger_streaming_stop(self) -> None:
pass await run_event_command(self.settings.event.streaming_stop)
async def trigger_detect(self) -> None:
await run_event_command(self.settings.event.detect)
async def trigger_detection(self, detection: Detection) -> None:
await run_event_command(self.settings.event.detection, detection.name)
await self._play_wav(self.settings.snd.awake_wav)
async def trigger_transcript(self, transcript: Transcript) -> None:
await run_event_command(self.settings.event.transcript, transcript.text)
await self._play_wav(self.settings.snd.done_wav)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -458,7 +516,15 @@ class AlwaysStreamingSatellite(SatelliteBase):
if RunSatellite.is_type(event.type): if RunSatellite.is_type(event.type):
self.is_streaming = True self.is_streaming = True
_LOGGER.info("Streaming audio") _LOGGER.info("Streaming audio")
await self.event_streaming_start() await self.trigger_streaming_start()
elif Detect.is_type(event.type):
await self.trigger_detect()
elif Detection.is_type(event.type):
_LOGGER.debug("Wake word detected")
await self.trigger_detection(Detection.from_event(event))
elif Transcript.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_transcript(Transcript.from_event(event))
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
@@ -499,6 +565,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 Detect.is_type(event.type):
await self.trigger_detect()
elif Detection.is_type(event.type):
_LOGGER.debug("Wake word detected")
await self.trigger_detection(Detection.from_event(event))
elif Transcript.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_transcript(Transcript.from_event(event))
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
@@ -519,7 +593,7 @@ class VadStreamingSatellite(SatelliteBase):
await self.event_to_server(AudioStop().event()) await self.event_to_server(AudioStop().event())
_LOGGER.info("Waiting for speech") _LOGGER.info("Waiting for speech")
await self.event_streaming_stop() await self.trigger_streaming_stop()
if not self.is_streaming: if not self.is_streaming:
# Check VAD # Check VAD
@@ -538,7 +612,7 @@ class VadStreamingSatellite(SatelliteBase):
# Speech detected # Speech detected
self.is_streaming = True self.is_streaming = True
_LOGGER.info("Streaming audio") _LOGGER.info("Streaming audio")
await self.event_streaming_start() await self.trigger_streaming_start()
if self.settings.vad.wake_word_timeout is not None: if self.settings.vad.wake_word_timeout is not None:
self.timeout_seconds = ( self.timeout_seconds = (
@@ -592,7 +666,9 @@ class WakeStreamingSatellite(SatelliteBase):
_LOGGER.info("Waiting for wake word") _LOGGER.info("Waiting for wake word")
if Transcript.is_type(event.type): if Transcript.is_type(event.type):
await self.event_streaming_stop() _LOGGER.debug(event)
await self.trigger_streaming_stop()
await self.trigger_transcript(Transcript.from_event(event))
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
@@ -614,4 +690,5 @@ class WakeStreamingSatellite(SatelliteBase):
if Detection.is_type(event.type): if Detection.is_type(event.type):
self.is_streaming = True self.is_streaming = True
_LOGGER.debug("Streaming audio") _LOGGER.debug("Streaming audio")
await self.event_streaming_start() await self.trigger_detection(Detection.from_event(event))
await self.trigger_streaming_start()
+12
View File
@@ -42,6 +42,7 @@ class SndSettings(ServiceSettings):
rate: int = 22050 rate: int = 22050
width: int = 2 width: int = 2
channels: int = 1 channels: int = 1
samples_per_chunk: int = 1024
@property @property
def needs_processing(self) -> bool: def needs_processing(self) -> bool:
@@ -67,7 +68,18 @@ class VadSettings:
@dataclass(frozen=True) @dataclass(frozen=True)
class EventSettings(ServiceSettings): class EventSettings(ServiceSettings):
startup: Optional[List[str]] = None
streaming_start: Optional[List[str]] = None streaming_start: Optional[List[str]] = None
streaming_stop: Optional[List[str]] = None
detect: Optional[List[str]] = None
detection: Optional[List[str]] = None
transcript: Optional[List[str]] = None
stt_start: Optional[List[str]] = None
stt_stop: Optional[List[str]] = None
synthesize: Optional[List[str]] = None
tts_start: Optional[List[str]] = None
tts_stop: Optional[List[str]] = None
error: Optional[List[str]] = None
@dataclass(frozen=True) @dataclass(frozen=True)
+41 -2
View File
@@ -2,8 +2,12 @@
import array import array
import asyncio import asyncio
import logging import logging
import shlex import wave
from typing import Iterable, List, Optional from pathlib import Path
from typing import Iterable, Iterator, List, Optional, Union
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.event import Event
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
@@ -113,3 +117,38 @@ async def run_event_command(
else: else:
proc.stdin.close() proc.stdin.close()
await proc.wait() await proc.wait()
def wav_to_events(
wav_path: Union[str, Path],
samples_per_chunk: int = 1024,
volume_multiplier: float = 1.0,
) -> Iterator[Event]:
"""Load WAV audio for playback on an event (wake/done)."""
with wave.open(str(wav_path), "rb") as wav_file:
rate = wav_file.getframerate()
width = wav_file.getsampwidth()
channels = wav_file.getnchannels()
timestamp = 0
yield AudioStart(
rate=rate, width=width, channels=channels, timestamp=timestamp
).event()
audio_bytes = wav_file.readframes(samples_per_chunk)
while audio_bytes:
if volume_multiplier != 1.0:
audio_bytes = multiply_volume(audio_bytes, volume_multiplier)
chunk = AudioChunk(
rate=rate,
width=width,
channels=channels,
audio=audio_bytes,
timestamp=timestamp,
)
yield chunk.event()
timestamp += int(chunk.seconds * 1000)
audio_bytes = wav_file.readframes(samples_per_chunk)
yield AudioStop(timestamp=timestamp).event()
+28
View File
@@ -0,0 +1,28 @@
"""webrtc audio processing."""
from typing import Final
from .utils import AudioBuffer, chunk_samples
class WebRtcAudio:
"""Audio processing using webrtc."""
_sub_chunk_samples: Final = 160 # 10ms @ 16Khz
_sub_chunk_bytes: Final = _sub_chunk_samples * 2 # 16-bit
def __init__(self, auto_gain: int, noise_suppression: int) -> None:
from webrtc_noise_gain import AudioProcessor
self.audio_processor = AudioProcessor(auto_gain, noise_suppression)
self.audio_buffer = AudioBuffer(self._sub_chunk_bytes)
def __call__(self, audio_bytes: bytes) -> bytes:
"""Process in 10ms chunks."""
clean_chunk = bytes()
for sub_chunk in chunk_samples(
audio_bytes, self._sub_chunk_bytes, self.audio_buffer
):
result = self.audio_processor.Process10ms(sub_chunk)
clean_chunk += result.audio
return clean_chunk