Working on audio enhancement
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
wyoming==1.3.0
|
wyoming==1.3.0
|
||||||
zeroconf==0.88.0
|
zeroconf==0.88.0
|
||||||
|
pyring-buffer==1.0.0
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pysilero-vad==1.0.0
|
||||||
|
webrtc-noise-gain==1.2.3
|
||||||
@@ -27,7 +27,7 @@ setup(
|
|||||||
classifiers=[
|
classifiers=[
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: Apache Software License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Programming Language :: Python :: 3.7",
|
"Programming Language :: Python :: 3.7",
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
@@ -35,4 +35,11 @@ setup(
|
|||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
],
|
],
|
||||||
keywords="rhasspy wyoming satellite",
|
keywords="rhasspy wyoming satellite",
|
||||||
|
extras_require={
|
||||||
|
"silerovad": ["onnxruntime>=1.10.0,<2", "numpy<1.26"],
|
||||||
|
"webrtc": ["webrtc-noise-gain==1.2.3"],
|
||||||
|
},
|
||||||
|
entry_points={
|
||||||
|
"console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"]
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+242
-19
@@ -2,24 +2,30 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
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 Optional
|
from typing import Callable, Final, Optional
|
||||||
|
|
||||||
from wyoming.asr import Transcript, Transcribe
|
from wyoming.asr import Transcribe, Transcript
|
||||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||||
from wyoming.client import AsyncClient
|
from wyoming.client import AsyncClient
|
||||||
from wyoming.event import Event
|
from wyoming.event import Event
|
||||||
from wyoming.info import Attribution, Describe, Info, Satellite
|
from wyoming.info import Attribution, Describe, Info, Satellite
|
||||||
|
from wyoming.mic import MicProcessAsyncClient
|
||||||
from wyoming.pipeline import PipelineStage, RunPipeline
|
from wyoming.pipeline import PipelineStage, RunPipeline
|
||||||
from wyoming.satellite import RunSatellite
|
from wyoming.satellite import RunSatellite
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer, AsyncTcpServer
|
from wyoming.server import AsyncEventHandler, AsyncServer, AsyncTcpServer
|
||||||
|
from wyoming.snd import SndProcessAsyncClient
|
||||||
from wyoming.tts import Synthesize
|
from wyoming.tts import Synthesize
|
||||||
from wyoming.vad import VoiceStarted, VoiceStopped
|
from wyoming.vad import VoiceStarted, VoiceStopped
|
||||||
from wyoming.wake import Detect, Detection
|
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
|
||||||
|
|
||||||
|
from .utils import AudioBuffer, chunk_samples, multiply_volume
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger()
|
||||||
_DIR = Path(__file__).parent
|
_DIR = Path(__file__).parent
|
||||||
@@ -33,11 +39,58 @@ class SatelliteState(str, Enum):
|
|||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
|
||||||
|
# Microphone input
|
||||||
|
parser.add_argument("--mic-uri", help="URI of Wyoming microphone service")
|
||||||
|
parser.add_argument("--mic-command", help="Program to run for microphone input")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--mic-uri", required=True, help="URI of Wyoming microphone service"
|
"--mic-command-rate",
|
||||||
|
type=int,
|
||||||
|
default=16000,
|
||||||
|
help="Sample rate of mic-command (hertz, default: 16000)",
|
||||||
)
|
)
|
||||||
#
|
parser.add_argument(
|
||||||
|
"--mic-command-width",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="Sample width of mic-command (bytes, default: 2)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mic-command-channels",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Sample channels of mic-command (default: 1)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mic-command-samples-per-chunk",
|
||||||
|
type=int,
|
||||||
|
default=1024,
|
||||||
|
help="Sample per chunk for mic-command (default: 1024)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sound output
|
||||||
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
|
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
|
||||||
|
parser.add_argument("--snd-command", help="Program to run for sound output")
|
||||||
|
parser.add_argument(
|
||||||
|
"--snd-command-rate",
|
||||||
|
type=int,
|
||||||
|
default=22050,
|
||||||
|
help="Sample rate of snd-command (hertz, default: 22050)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--snd-command-width",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="Sample width of snd-command (bytes, default: 2)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--snd-command-channels",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Sample channels of snd-command (default: 1)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Local wake word detection
|
||||||
parser.add_argument("--wake-uri", help="URI of Wyoming wake word detection service")
|
parser.add_argument("--wake-uri", help="URI of Wyoming wake word detection service")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wake-word-name",
|
"--wake-word-name",
|
||||||
@@ -45,16 +98,53 @@ async def main() -> None:
|
|||||||
default=[],
|
default=[],
|
||||||
help="Name of wake word to listen for (requires --wake-uri)",
|
help="Name of wake word to listen for (requires --wake-uri)",
|
||||||
)
|
)
|
||||||
#
|
parser.add_argument("--wake-command", help="Program to run for wake word detection")
|
||||||
|
parser.add_argument(
|
||||||
|
"--wake-command-rate",
|
||||||
|
type=int,
|
||||||
|
default=16000,
|
||||||
|
help="Sample rate of wake-command (hertz, default: 16000)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--wake-command-width",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="Sample width of wake-command (bytes, default: 2)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--wake-command-channels",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Sample channels of wake-command (default: 1)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Voice activity detector
|
||||||
|
parser.add_argument(
|
||||||
|
"--vad", choices=("disabled", "webrtcvad", "silero"), default="disabled"
|
||||||
|
)
|
||||||
|
parser.add_argument("--vad-threshold", type=float, default=0.5)
|
||||||
|
parser.add_argument("--vad-trigger-level", type=int, default=3)
|
||||||
|
parser.add_argument("--vad-buffer-chunks", type=int, default=40)
|
||||||
|
|
||||||
|
# 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)))
|
||||||
|
parser.add_argument("--volume-multiplier", type=float, default=1.0)
|
||||||
|
|
||||||
|
# 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"
|
||||||
)
|
)
|
||||||
#
|
|
||||||
|
# Satellite details
|
||||||
parser.add_argument("--uri", default="stdio://", help="unix:// or tcp://")
|
parser.add_argument("--uri", default="stdio://", help="unix:// or tcp://")
|
||||||
parser.add_argument("--name", required=True, help="Name of the satellite")
|
parser.add_argument("--name", required=True, help="Name of the satellite")
|
||||||
parser.add_argument("--area", help="Area name of the satellite")
|
parser.add_argument("--area", help="Area name of the satellite")
|
||||||
parser.add_argument("--pipeline", help="Name of server pipeline to run")
|
parser.add_argument("--pipeline", help="Name of server pipeline to run")
|
||||||
#
|
|
||||||
|
# Zeroconf
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-zeroconf", action="store_true", help="Disable discovery over zeroconf"
|
"--no-zeroconf", action="store_true", help="Disable discovery over zeroconf"
|
||||||
)
|
)
|
||||||
@@ -66,6 +156,25 @@ async def main() -> None:
|
|||||||
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()
|
||||||
|
|
||||||
|
# Validate args
|
||||||
|
if (not args.mic_uri) and (not args.mic_command):
|
||||||
|
_LOGGER.fatal("Either --mic-uri or --mic-command is required")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if needs_webrtc(args):
|
||||||
|
try:
|
||||||
|
import webrtc_noise_gain # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
_LOGGER.fatal("Install extras for webrtc")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if needs_silero(args):
|
||||||
|
try:
|
||||||
|
import pysilero_vad # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
_LOGGER.fatal("Install extras for silero")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -125,11 +234,22 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
|
|
||||||
self.state: Optional[Satellite] = None
|
self.state: Optional[Satellite] = None
|
||||||
self.satellite_task: Optional[asyncio.Task] = None
|
self.satellite_task: Optional[asyncio.Task] = None
|
||||||
self.has_snd = bool(self.cli_args.snd_uri)
|
self.volume_multiplier = self.cli_args.volume_multiplier
|
||||||
|
self.has_audio_processing = needs_webrtc(self.cli_args) or (
|
||||||
|
self.volume_multiplier != 1.0
|
||||||
|
)
|
||||||
|
self.has_wake = bool(self.cli_args.wake_uri or self.cli_args.wake_command)
|
||||||
|
self.has_snd = bool(self.cli_args.snd_uri or self.cli_args.snd_command)
|
||||||
self.snd_client: Optional[AsyncClient] = None
|
self.snd_client: Optional[AsyncClient] = None
|
||||||
self.events_client: Optional[AsyncClient] = None
|
self.events_client: Optional[AsyncClient] = None
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
|
|
||||||
|
self.process_webrtc: Optional[Callable[[bytes], bytes]] = None
|
||||||
|
if needs_webrtc(self.cli_args):
|
||||||
|
self.process_webrtc = WebRtcAudio(
|
||||||
|
self.cli_args.auto_gain, self.cli_args.noise_suppression
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.debug("Client connected: %s", self.client_id)
|
_LOGGER.debug("Client connected: %s", self.client_id)
|
||||||
|
|
||||||
async def handle_event(self, event: Event) -> bool:
|
async def handle_event(self, event: Event) -> bool:
|
||||||
@@ -139,7 +259,7 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
if RunSatellite.is_type(event.type) and (self.satellite_task is None):
|
if RunSatellite.is_type(event.type) and (self.satellite_task is None):
|
||||||
if self.cli_args.wake_uri:
|
if self.has_wake:
|
||||||
# Local wake word detection
|
# Local wake word detection
|
||||||
await self.write_event(
|
await self.write_event(
|
||||||
RunPipeline(
|
RunPipeline(
|
||||||
@@ -173,13 +293,27 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
|
|
||||||
# STT transcript
|
# STT transcript
|
||||||
await self.forward_event(event)
|
await self.forward_event(event)
|
||||||
|
_LOGGER.debug(event)
|
||||||
elif self.has_snd and (
|
elif self.has_snd and (
|
||||||
AudioStart.is_type(event.type)
|
AudioStart.is_type(event.type)
|
||||||
or AudioChunk.is_type(event.type)
|
or AudioChunk.is_type(event.type)
|
||||||
or AudioStop.is_type(event.type)
|
or AudioStop.is_type(event.type)
|
||||||
):
|
):
|
||||||
if self.snd_client is None:
|
if self.snd_client is None:
|
||||||
|
if self.cli_args.snd_command:
|
||||||
|
_LOGGER.debug("Running %s", self.cli_args.snd_command)
|
||||||
|
program, *program_args = shlex.split(self.cli_args.snd_command)
|
||||||
|
self.snd_client = SndProcessAsyncClient(
|
||||||
|
rate=self.cli_args.snd_command_rate,
|
||||||
|
width=self.cli_args.snd_command_width,
|
||||||
|
channels=self.cli_args.snd_command_channels,
|
||||||
|
program=program,
|
||||||
|
program_args=program_args,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Connecting to %s", self.cli_args.snd_uri)
|
||||||
self.snd_client = AsyncClient.from_uri(self.cli_args.snd_uri)
|
self.snd_client = AsyncClient.from_uri(self.cli_args.snd_uri)
|
||||||
|
|
||||||
await self.snd_client.connect()
|
await self.snd_client.connect()
|
||||||
|
|
||||||
assert self.snd_client is not None
|
assert self.snd_client is not None
|
||||||
@@ -196,6 +330,7 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
if AudioStart.is_type(event.type) or AudioStop.is_type(event.type):
|
if AudioStart.is_type(event.type) or AudioStop.is_type(event.type):
|
||||||
# TTS start/stop
|
# TTS start/stop
|
||||||
await self.forward_event(event)
|
await self.forward_event(event)
|
||||||
|
_LOGGER.debug(event)
|
||||||
elif (
|
elif (
|
||||||
Detect.is_type(event.type)
|
Detect.is_type(event.type)
|
||||||
or Detection.is_type(event.type)
|
or Detection.is_type(event.type)
|
||||||
@@ -211,15 +346,33 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
# - VoiceStarted/VoiceStopped for when user starts/stops speaking
|
# - VoiceStarted/VoiceStopped for when user starts/stops speaking
|
||||||
# - Synthesize for TTS text
|
# - Synthesize for TTS text
|
||||||
await self.forward_event(event)
|
await self.forward_event(event)
|
||||||
|
_LOGGER.debug(event)
|
||||||
else:
|
else:
|
||||||
_LOGGER.debug("Unexpected event: type=%s, data=%s", event.type, event.data)
|
_LOGGER.debug("Unexpected event: type=%s, data=%s", event.type, event.data)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _make_mic_client(self) -> AsyncClient:
|
||||||
|
if self.cli_args.mic_command:
|
||||||
|
_LOGGER.debug("Running %s", self.cli_args.mic_command)
|
||||||
|
program, *program_args = shlex.split(self.cli_args.mic_command)
|
||||||
|
return MicProcessAsyncClient(
|
||||||
|
rate=self.cli_args.mic_command_rate,
|
||||||
|
width=self.cli_args.mic_command_width,
|
||||||
|
channels=self.cli_args.mic_command_channels,
|
||||||
|
samples_per_chunk=self.cli_args.mic_command_samples_per_chunk,
|
||||||
|
program=program,
|
||||||
|
program_args=program_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert self.cli_args.mic_uri
|
||||||
|
_LOGGER.debug("Connecting to %s", self.cli_args.mic_uri)
|
||||||
|
return AsyncClient.from_uri(self.cli_args.mic_uri)
|
||||||
|
|
||||||
async def run_satellite(self) -> None:
|
async def run_satellite(self) -> None:
|
||||||
try:
|
try:
|
||||||
_LOGGER.debug("Connecting to %s", self.cli_args.mic_uri)
|
mic_client = self._make_mic_client()
|
||||||
async with AsyncClient.from_uri(self.cli_args.mic_uri) as mic_client:
|
async with mic_client:
|
||||||
_LOGGER.info("Streaming audio")
|
_LOGGER.info("Streaming audio")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -229,6 +382,8 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if AudioChunk.is_type(mic_event.type):
|
if AudioChunk.is_type(mic_event.type):
|
||||||
|
mic_event = self._process_audio(mic_event)
|
||||||
|
|
||||||
# Forward all audio to server
|
# Forward all audio to server
|
||||||
await self.write_event(mic_event)
|
await self.write_event(mic_event)
|
||||||
|
|
||||||
@@ -237,16 +392,25 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
|
|
||||||
async def run_satellite_wake(self) -> None:
|
async def run_satellite_wake(self) -> None:
|
||||||
try:
|
try:
|
||||||
assert self.cli_args.wake_uri
|
assert self.cli_args.wake_uri or self.cli_args.wake_command
|
||||||
|
|
||||||
_LOGGER.debug("Connecting to %s", self.cli_args.mic_uri)
|
mic_client = self._make_mic_client()
|
||||||
|
|
||||||
|
if self.cli_args.wake_command:
|
||||||
|
_LOGGER.debug("Running %s", self.cli_args.wake_command)
|
||||||
|
program, *program_args = shlex.split(self.cli_args.wake_command)
|
||||||
|
wake_client = WakeProcessAsyncClient(
|
||||||
|
rate=self.cli_args.wake_command_rate,
|
||||||
|
width=self.cli_args.wake_command_width,
|
||||||
|
channels=self.cli_args.wake_command_channels,
|
||||||
|
program=program,
|
||||||
|
program_args=program_args,
|
||||||
|
)
|
||||||
|
else:
|
||||||
_LOGGER.debug("Connecting to %s", self.cli_args.wake_uri)
|
_LOGGER.debug("Connecting to %s", self.cli_args.wake_uri)
|
||||||
|
wake_client = AsyncClient.from_uri(self.cli_args.wake_uri)
|
||||||
|
|
||||||
async with AsyncClient.from_uri(
|
async with mic_client, wake_client:
|
||||||
self.cli_args.mic_uri
|
|
||||||
) as mic_client, AsyncClient.from_uri(
|
|
||||||
self.cli_args.wake_uri
|
|
||||||
) as wake_client:
|
|
||||||
_LOGGER.info("Detecting wake word")
|
_LOGGER.info("Detecting wake word")
|
||||||
|
|
||||||
if self.cli_args.wake_word_name:
|
if self.cli_args.wake_word_name:
|
||||||
@@ -257,6 +421,7 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
|
|
||||||
await wake_client.write_event(detect_event)
|
await wake_client.write_event(detect_event)
|
||||||
await self.forward_event(detect_event)
|
await self.forward_event(detect_event)
|
||||||
|
_LOGGER.debug(detect_event)
|
||||||
|
|
||||||
# Read events in parallel
|
# Read events in parallel
|
||||||
mic_task = asyncio.create_task(mic_client.read_event())
|
mic_task = asyncio.create_task(mic_client.read_event())
|
||||||
@@ -275,6 +440,8 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if AudioChunk.is_type(mic_event.type):
|
if AudioChunk.is_type(mic_event.type):
|
||||||
|
mic_event = self._process_audio(mic_event)
|
||||||
|
|
||||||
# Forward to wake service
|
# Forward to wake service
|
||||||
await wake_client.write_event(mic_event)
|
await wake_client.write_event(mic_event)
|
||||||
|
|
||||||
@@ -311,6 +478,28 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("Unexpected error in run_satellite")
|
_LOGGER.exception("Unexpected error in run_satellite")
|
||||||
|
|
||||||
|
def _process_audio(self, audio_event: Event) -> Event:
|
||||||
|
"""Perform audio processing."""
|
||||||
|
if not self.has_audio_processing:
|
||||||
|
# No processing needed
|
||||||
|
return audio_event
|
||||||
|
|
||||||
|
chunk = AudioChunk.from_event(audio_event)
|
||||||
|
audio_bytes = chunk.audio
|
||||||
|
|
||||||
|
if self.process_webrtc is not None:
|
||||||
|
audio_bytes = self.process_webrtc(audio_bytes)
|
||||||
|
|
||||||
|
if self.volume_multiplier != 1.0:
|
||||||
|
audio_bytes = multiply_volume(audio_bytes, self.volume_multiplier)
|
||||||
|
|
||||||
|
return AudioChunk(
|
||||||
|
rate=chunk.rate,
|
||||||
|
width=chunk.width,
|
||||||
|
channels=chunk.channels,
|
||||||
|
audio=audio_bytes,
|
||||||
|
).event()
|
||||||
|
|
||||||
async def forward_event(self, event: Event) -> None:
|
async def forward_event(self, event: Event) -> None:
|
||||||
"""Forward a Wyoming event to a client."""
|
"""Forward a Wyoming event to a client."""
|
||||||
if not self.cli_args.event_uri:
|
if not self.cli_args.event_uri:
|
||||||
@@ -334,12 +523,46 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
def get_mac_address() -> str:
|
def get_mac_address() -> str:
|
||||||
"""Return MAC address formatted as hex with no colons."""
|
"""Return MAC address formatted as hex with no colons."""
|
||||||
return "".join(
|
return "".join(
|
||||||
|
# pylint: disable=consider-using-f-string
|
||||||
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
|
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
|
||||||
::-1
|
::-1
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def needs_webrtc(args: argparse.Namespace) -> bool:
|
||||||
|
"""Return True if webrtc must be used."""
|
||||||
|
return (
|
||||||
|
(args.noise_suppression > 0) or (args.auto_gain > 0) or (args.vad == "webrtc")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def needs_silero(args: argparse.Namespace) -> bool:
|
||||||
|
"""Return True if silero-vad must be used."""
|
||||||
|
return args.vad == "silero"
|
||||||
|
|
||||||
|
|
||||||
|
class WebRtcAudio:
|
||||||
|
_sub_chunk_samples: Final = 160
|
||||||
|
_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:
|
||||||
|
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__":
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Utilities for Wyoming satellite."""
|
||||||
|
import array
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
class AudioBuffer:
|
||||||
|
"""Fixed-sized audio buffer with variable internal length."""
|
||||||
|
|
||||||
|
def __init__(self, maxlen: int) -> None:
|
||||||
|
"""Initialize buffer."""
|
||||||
|
self._buffer = bytearray(maxlen)
|
||||||
|
self._length = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length(self) -> int:
|
||||||
|
"""Get number of bytes currently in the buffer."""
|
||||||
|
return self._length
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the buffer."""
|
||||||
|
self._length = 0
|
||||||
|
|
||||||
|
def append(self, data: bytes) -> None:
|
||||||
|
"""Append bytes to the buffer, increasing the internal length."""
|
||||||
|
data_len = len(data)
|
||||||
|
if (self._length + data_len) > len(self._buffer):
|
||||||
|
raise ValueError("Length cannot be greater than buffer size")
|
||||||
|
|
||||||
|
self._buffer[self._length : self._length + data_len] = data
|
||||||
|
self._length += data_len
|
||||||
|
|
||||||
|
def to_bytes(self) -> bytes:
|
||||||
|
"""Convert written portion of buffer to bytes."""
|
||||||
|
return bytes(self._buffer[: self._length])
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
"""Get the number of bytes currently in the buffer."""
|
||||||
|
return self._length
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
"""Return True if there are bytes in the buffer."""
|
||||||
|
return self._length > 0
|
||||||
|
|
||||||
|
|
||||||
|
def multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes:
|
||||||
|
"""Multiplies 16-bit PCM samples by a constant."""
|
||||||
|
|
||||||
|
def _clamp(val: float) -> float:
|
||||||
|
"""Clamp to signed 16-bit."""
|
||||||
|
return max(-32768, min(32767, val))
|
||||||
|
|
||||||
|
return array.array(
|
||||||
|
"h",
|
||||||
|
(int(_clamp(value * volume_multiplier)) for value in array.array("h", chunk)),
|
||||||
|
).tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_samples(
|
||||||
|
samples: bytes,
|
||||||
|
bytes_per_chunk: int,
|
||||||
|
leftover_chunk_buffer: AudioBuffer,
|
||||||
|
) -> Iterable[bytes]:
|
||||||
|
"""Yield fixed-sized chunks from samples, keeping leftover bytes from previous call(s)."""
|
||||||
|
|
||||||
|
if (len(leftover_chunk_buffer) + len(samples)) < bytes_per_chunk:
|
||||||
|
# Extend leftover chunk, but not enough samples to complete it
|
||||||
|
leftover_chunk_buffer.append(samples)
|
||||||
|
return
|
||||||
|
|
||||||
|
next_chunk_idx = 0
|
||||||
|
|
||||||
|
if leftover_chunk_buffer:
|
||||||
|
# Add to leftover chunk from previous call(s).
|
||||||
|
bytes_to_copy = bytes_per_chunk - len(leftover_chunk_buffer)
|
||||||
|
leftover_chunk_buffer.append(samples[:bytes_to_copy])
|
||||||
|
next_chunk_idx = bytes_to_copy
|
||||||
|
|
||||||
|
# Process full chunk in buffer
|
||||||
|
yield leftover_chunk_buffer.to_bytes()
|
||||||
|
leftover_chunk_buffer.clear()
|
||||||
|
|
||||||
|
while next_chunk_idx < len(samples) - bytes_per_chunk + 1:
|
||||||
|
# Process full chunk
|
||||||
|
yield samples[next_chunk_idx : next_chunk_idx + bytes_per_chunk]
|
||||||
|
next_chunk_idx += bytes_per_chunk
|
||||||
|
|
||||||
|
# Capture leftover chunks
|
||||||
|
if rest_samples := samples[next_chunk_idx:]:
|
||||||
|
leftover_chunk_buffer.append(rest_samples)
|
||||||
Reference in New Issue
Block a user